feat(web): add block drag handles and formula insert

Let users reorder blocks from the desktop rich editor, and insert or edit LaTeX from the toolbar, slash menu, or by clicking a formula.
This commit is contained in:
tianma-if committed 2026-09-20 23:42:38 +08:00
1 parent 9ddb3466bc
commit 5880699347
17 files changed
+722 -14

No files matched your search

+96 -7
View File
@@ -95,6 +95,15 @@ const MarkdownSourceEditor = lazy(() =>
import { sanitizeAndScopeCss } from "@/lib/css-sandbox";
import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog";
import { ExternalLinkDialog } from "./dialogs/ExternalLinkDialog";
import { MathFormulaDialog } from "./dialogs/MathFormulaDialog";
import { EditorBlockDragHandle } from "./editor/EditorBlockDragHandle";
import {
applyMathFormula,
deleteMathFormula,
resolveMathFormulaTarget,
selectedTextAsLatex,
type MathFormulaDraft,
} from "./editor/math-formula";
import { memoShareQueryKey, ShareMemoDialog } from "./dialogs/ShareMemoDialog";
import { ShareNoteImageDialog } from "./dialogs/ShareNoteImageDialog";
import { AiAssistantDialog, type AiAssistantAnchor } from "./dialogs/AiAssistantDialog";
@@ -255,6 +264,7 @@ import {
getNoteLinkFromEventTarget,
getNoteLinkHintPosition,
getResourceFilesFromDataTransfer,
shouldInsertDroppedResourceFiles,
isCreatedMemoEditorFocused,
isEditorReady,
MemoSaveRequestError,
@@ -446,6 +456,8 @@ const RichEditorPane = ({
showTextField: true,
canRemove: false,
});
const [mathFormulaOpen, setMathFormulaOpen] = useState(false);
const [mathFormulaDraft, setMathFormulaDraft] = useState<MathFormulaDraft | null>(null);
const {
menuTarget: resourceMenuTarget,
dialog: resourceDialog,
@@ -575,6 +587,7 @@ const RichEditorPane = ({
setAiInsertionTarget(null);
closeNoteReplaceRef.current();
setExternalLinkDialogOpen(false);
setMathFormulaOpen(false);
setNoteLinkPickerOpen(false);
}, [desktopReadingProtection]);
@@ -590,6 +603,12 @@ const RichEditorPane = ({
const mobileSaveTimerRef = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openExternalLinkDialogRef = useRef<() => void>(() => undefined);
const openMathFormulaRef = useRef<(kind?: "inline" | "block") => void>(() => undefined);
const mathClickRef = useRef<(node: { attrs: Record<string, unknown> }, pos: number, kind: "inline" | "block") => void>(() => undefined);
const mathematicsExtensionsRef = useRef(createEdgeEverMathematics({
onInlineClick: (node, pos) => mathClickRef.current(node, pos, "inline"),
onBlockClick: (node, pos) => mathClickRef.current(node, pos, "block"),
}));
const slashCommandLabelsRef = useRef<SlashCommandLabels>({
menu: "",
empty: "",
@@ -612,6 +631,8 @@ const RichEditorPane = ({
divider: "",
fold: "",
table: "",
"inline-math": "",
"block-math": "",
"current-date": "",
"current-time": "",
"current-date-time": "",
@@ -646,6 +667,8 @@ const RichEditorPane = ({
divider: t("editorToolbar.horizontalRule"),
fold: t("editorToolbar.fold"),
table: t("editorToolbar.table"),
"inline-math": t("editorToolbar.math"),
"block-math": t("editorToolbar.blockMath"),
"current-date": t("slashMenu.items.currentDate"),
"current-time": t("slashMenu.items.currentTime"),
"current-date-time": t("slashMenu.items.currentDateTime"),
@@ -661,6 +684,7 @@ const RichEditorPane = ({
openAttachmentPicker: () => fileInputRef.current?.click(),
openExternalLinkPicker: () => openExternalLinkDialogRef.current(),
openNoteLinkPicker: () => setNoteLinkPickerOpen(true),
openMathFormula: (kind) => openMathFormulaRef.current(kind),
};
}
const slashCommandExtensionRef = useRef<ReturnType<typeof createSlashCommandExtension> | null>(null);
@@ -1064,8 +1088,12 @@ const RichEditorPane = ({
const editor = useEditor({
extensions: [
...createEdgeEverDocumentExtensions({
mathematics: createEdgeEverMathematics(),
starterKit: { codeBlock: false, link: false },
mathematics: mathematicsExtensionsRef.current,
starterKit: {
codeBlock: false,
link: false,
dropcursor: { color: "#16A06E", width: 2 },
},
image: false,
gallery: EditableImageGallery,
pdf: PdfAttachment,
@@ -1215,15 +1243,16 @@ const RichEditorPane = ({
insertResourceFiles(files);
return true;
},
handleDrop: (_view, event) => {
const files = getResourceFilesFromDataTransfer(event.dataTransfer);
if (files.length === 0) {
handleDrop: (view, event) => {
if (!shouldInsertDroppedResourceFiles({
dataTransfer: event.dataTransfer,
isInternalNodeDrag: Boolean(view.dragging),
})) {
return false;
}
event.preventDefault();
insertResourceFiles(files);
insertResourceFiles(getResourceFilesFromDataTransfer(event.dataTransfer));
return true;
},
},
@@ -1350,6 +1379,42 @@ const RichEditorPane = ({
openExternalLinkDialogRef.current = openExternalLinkDialog;
const openMathFormula = useCallback((kind: "inline" | "block" = "inline") => {
if (effectiveReadOnly || !isEditorReady(editor) || useMarkdownSourceEditor || useMobilePlainTextEditor) {
return;
}
const existing = resolveMathFormulaTarget(editor);
if (existing) {
setMathFormulaDraft(existing);
setMathFormulaOpen(true);
return;
}
const { from, to } = editor.state.selection;
setMathFormulaDraft({
kind,
latex: selectedTextAsLatex(editor),
from,
to,
});
setMathFormulaOpen(true);
}, [editor, effectiveReadOnly, useMarkdownSourceEditor, useMobilePlainTextEditor]);
openMathFormulaRef.current = openMathFormula;
mathClickRef.current = (_node, pos, kind) => {
if (effectiveReadOnly || !isEditorReady(editor) || useMarkdownSourceEditor || useMobilePlainTextEditor) {
return;
}
const existing = resolveMathFormulaTarget(editor, pos);
setMathFormulaDraft(existing ?? {
kind,
latex: String(_node.attrs.latex ?? ""),
pos,
});
setMathFormulaOpen(true);
};
const applyExternalLink = useCallback(
({ href, text }: { href: string; text: string }) => {
if (effectiveReadOnly) {
@@ -3396,6 +3461,25 @@ const RichEditorPane = ({
onApply={applyExternalLink}
onRemove={removeExternalLink}
/>
<MathFormulaDialog
open={mathFormulaOpen}
draft={mathFormulaDraft}
onOpenChange={setMathFormulaOpen}
onApply={(draft) => {
if (!isEditorReady(editor) || effectiveReadOnly) return;
editor.commands.focus();
applyMathFormula(editor, draft);
}}
onRemove={
mathFormulaDraft && typeof mathFormulaDraft.pos === "number"
? () => {
if (!isEditorReady(editor) || effectiveReadOnly) return;
editor.commands.focus();
deleteMathFormula(editor, { kind: mathFormulaDraft.kind, pos: mathFormulaDraft.pos as number });
}
: undefined
}
/>
{noteLinkPickerOpen && (
<EditorNoteLinkPicker
query={noteLinkQuery}
@@ -3818,6 +3902,7 @@ const RichEditorPane = ({
onPickExternalLink={openExternalLinkDialog}
externalLinkActive={externalLinkActive}
onPickNoteLink={() => setNoteLinkPickerOpen(true)}
onPickMathFormula={() => openMathFormula()}
/>
)}
<EditorSaveRecoveryBanner
@@ -3983,6 +4068,7 @@ const RichEditorPane = ({
</div>
) : (
<div
className="relative"
onMouseOver={handleEditorMouseOver}
onMouseOut={handleEditorMouseOut}
onFocusCapture={handleEditorFocusCapture}
@@ -4005,6 +4091,9 @@ const RichEditorPane = ({
{t("aiAssistant.openForSelection")}
</Button>
</BubbleMenu>
{!isMobileViewport && !effectiveReadOnly && isEditorReady(editor) ? (
<EditorBlockDragHandle editor={editor} />
) : null}
<EditorContent editor={editor} />
</div>
)}
+13
View File
@@ -18,6 +18,7 @@ import {
Paperclip,
Link,
Link2,
Sigma,
ChevronDown,
ChevronUp,
} from "lucide-react";
@@ -155,6 +156,7 @@ export const EditorToolbar = ({
onPickAttachment,
onPickExternalLink,
onPickNoteLink,
onPickMathFormula,
externalLinkActive = false,
}: {
editor: Editor | null;
@@ -166,6 +168,7 @@ export const EditorToolbar = ({
/** Insert or edit an external hyperlink (not a note reference). */
onPickExternalLink?: () => void;
onPickNoteLink?: () => void;
onPickMathFormula?: () => void;
externalLinkActive?: boolean;
}) => {
const { t } = useTranslation();
@@ -564,6 +567,16 @@ 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}
@@ -0,0 +1,201 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import katex from "katex";
import { Sigma } from "lucide-react";
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 "";
}
};
export const MathFormulaDialog = ({
open,
draft,
onOpenChange,
onApply,
onRemove,
}: {
open: boolean;
draft: MathFormulaDraft | null;
onOpenChange: (open: boolean) => void;
onApply: (draft: MathFormulaDraft) => void;
onRemove?: () => void;
}) => {
const { t } = 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 editing = typeof draft?.pos === "number";
useEffect(() => {
if (!open) return;
setKind(draft?.kind ?? "inline");
setLatex(draft?.latex ?? "");
setError(null);
const timer = window.setTimeout(() => {
latexInputRef.current?.focus();
latexInputRef.current?.select();
}, 0);
return () => window.clearTimeout(timer);
}, [draft, open]);
const previewHtml = useMemo(() => renderPreviewHtml(latex, kind), [kind, latex]);
const submit = () => {
if (!latex.trim()) {
setError(t("mathFormulaDialog.errorEmpty"));
return;
}
onApply({
kind,
latex,
pos: draft?.pos,
from: draft?.from,
to: draft?.to,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg gap-0 overflow-hidden 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-base">
<Sigma className="h-5 w-5 text-emerald-600" />
{editing ? t("mathFormulaDialog.editTitle") : t("mathFormulaDialog.title")}
</DialogTitle>
<DialogDescription className="pt-1 leading-5">
{t("mathFormulaDialog.description")}
</DialogDescription>
</DialogHeader>
<form
className="space-y-4 px-5 py-4"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
{!editing && (
<div className="flex rounded-md border border-slate-200 p-0.5">
{(["inline", "block"] as const).map((value) => (
<button
key={value}
type="button"
className={
kind === value
? "flex-1 rounded-sm bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-800"
: "flex-1 rounded-sm px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
}
aria-pressed={kind === value}
onClick={() => setKind(value)}
>
{value === "inline" ? t("mathFormulaDialog.inline") : t("mathFormulaDialog.block")}
</button>
))}
</div>
)}
<div className="space-y-1.5">
<label htmlFor={latexId} className="text-xs font-medium text-slate-600">
{t("mathFormulaDialog.latexLabel")}
</label>
<textarea
ref={latexInputRef}
id={latexId}
value={latex}
rows={3}
spellCheck={false}
className="w-full resize-y rounded-md border border-slate-200 bg-card px-3 py-2 font-mono text-sm text-slate-900 outline-none ring-emerald-500/30 placeholder:text-slate-400 focus:border-emerald-400 focus:ring-2"
placeholder={t("mathFormulaDialog.latexPlaceholder")}
aria-invalid={Boolean(error) || undefined}
onChange={(event) => {
setLatex(event.target.value);
if (error) setError(null);
}}
/>
</div>
<div className="space-y-1.5">
<p id={previewId} className="text-xs font-medium text-slate-600">
{t("mathFormulaDialog.preview")}
</p>
<div
aria-labelledby={previewId}
className={
kind === "block"
? "min-h-16 overflow-x-auto rounded-md border border-slate-200 bg-slate-50 px-3 py-4 text-center"
: "min-h-12 overflow-x-auto rounded-md border border-slate-200 bg-slate-50 px-3 py-3"
}
>
{latex.trim() ? (
previewHtml ? (
<span dangerouslySetInnerHTML={{ __html: previewHtml }} />
) : (
<p className="text-sm text-rose-700">{t("mathFormulaDialog.previewError")}</p>
)
) : (
<p className="text-sm text-slate-400">{t("mathFormulaDialog.previewEmpty")}</p>
)}
</div>
</div>
{error && (
<p className="text-xs text-rose-600" role="alert">
{error}
</p>
)}
<DialogFooter className="gap-2 border-t border-slate-100 pt-4 sm:justify-between">
<div className="flex min-w-0 flex-1 flex-wrap gap-2">
{editing && onRemove && (
<Button
type="button"
variant="ghost"
className="text-rose-700 hover:bg-rose-50 hover:text-rose-800"
onClick={() => {
onRemove();
onOpenChange(false);
}}
>
{t("mathFormulaDialog.remove")}
</Button>
)}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="solid">
{t("mathFormulaDialog.apply")}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,47 @@
import { useEffect } from "react";
import type { Editor } from "@tiptap/react";
import DragHandle from "@tiptap/extension-drag-handle-react";
import { GripVertical } from "lucide-react";
import { useTranslation } from "react-i18next";
const BLOCK_DRAG_HANDLE_CLASS_NAME = "edgeever-block-drag-handle";
const isBlockDragHandleEvent = (event: DragEvent) => {
const target = event.target;
return target instanceof Element && Boolean(target.closest(`.${BLOCK_DRAG_HANDLE_CLASS_NAME}`));
};
/** TipTap clears dataTransfer during dragstart; Chrome then refuses the drop unless a type remains. */
const retainBlockDragDataTransfer = (event: DragEvent) => {
if (!isBlockDragHandleEvent(event) || !event.dataTransfer) {
return;
}
event.dataTransfer.effectAllowed = "move";
if (![...event.dataTransfer.types].includes("text/plain")) {
event.dataTransfer.setData("text/plain", " ");
}
};
export const EditorBlockDragHandle = ({ editor }: { editor: Editor }) => {
const { t } = useTranslation();
useEffect(() => {
document.addEventListener("dragstart", retainBlockDragDataTransfer);
return () => document.removeEventListener("dragstart", retainBlockDragDataTransfer);
}, []);
if (editor.isDestroyed || !editor.isEditable) {
return null;
}
return (
<DragHandle
editor={editor}
className={BLOCK_DRAG_HANDLE_CLASS_NAME}
nested
>
<span className="sr-only">{t("editor.dragHandle")}</span>
<GripVertical aria-hidden="true" className="h-4 w-4" />
</DragHandle>
);
};
@@ -29,6 +29,8 @@ const labels = {
divider: "分割线",
fold: "折叠块",
table: "表格",
"inline-math": "插入公式",
"block-math": "块级公式",
"current-date": "当前日期",
"current-time": "当前时间",
"current-date-time": "当前日期和时间",
@@ -45,7 +47,7 @@ describe("slash command menu", () => {
const unfilteredItems = filterSlashCommandItems(items, "");
const heading6Index = unfilteredItems.findIndex((item) => item.id === "heading-6");
expect(unfilteredItems).toHaveLength(22);
expect(unfilteredItems).toHaveLength(24);
expect(unfilteredItems.slice(heading6Index + 1, heading6Index + 4).map((item) => item.id)).toEqual([
"current-date",
"current-time",
@@ -83,6 +85,8 @@ describe("slash command menu", () => {
"divider",
"fold",
"table",
"math",
"equation",
"upload",
"note",
"link",
@@ -93,6 +97,8 @@ describe("slash command menu", () => {
expect(filterSlashCommandItems(items, "collapse").map((item) => item.id)).toEqual(["fold"]);
expect(filterSlashCommandItems(items, "table").map((item) => item.id)).toEqual(["table"]);
expect(filterSlashCommandItems(items, "task").map((item) => item.id)).toEqual(["task-list"]);
expect(filterSlashCommandItems(items, "公式").map((item) => item.id)).toEqual(["inline-math", "block-math"]);
expect(filterSlashCommandItems(items, "equation").map((item) => item.id)).toEqual(["block-math"]);
});
test("formats local date and time as stable static text", () => {
@@ -24,6 +24,7 @@ import {
ListTodo,
Pilcrow,
Quote,
Sigma,
Table2,
} from "lucide-react";
import {
@@ -53,6 +54,8 @@ export type SlashCommandId =
| "divider"
| "fold"
| "table"
| "inline-math"
| "block-math"
| "current-date"
| "current-time"
| "current-date-time"
@@ -76,6 +79,7 @@ export type SlashCommandActions = {
openAttachmentPicker: () => void;
openExternalLinkPicker: () => void;
openNoteLinkPicker: () => void;
openMathFormula: (kind: "inline" | "block") => void;
};
export type SlashCommandItem = {
@@ -120,6 +124,8 @@ export const createSlashCommandItems = (labels: SlashCommandLabels): SlashComman
{ id: "divider", command: "divider", group: "insert", icon: BetweenHorizontalStart, label: labels.items.divider, keywords: ["rule", "分割", "分隔"] },
{ id: "fold", command: "fold", group: "insert", icon: ChevronsDownUp, label: labels.items.fold, keywords: ["collapse", "spoiler", "折叠", "折りたたみ"] },
{ id: "table", command: "table", group: "insert", icon: Table2, label: labels.items.table, keywords: ["表格"] },
{ id: "inline-math", command: "math", group: "insert", icon: Sigma, label: labels.items["inline-math"], keywords: ["latex", "formula", "katex", "公式", "数学", "数式"] },
{ id: "block-math", command: "equation", group: "insert", icon: Sigma, label: labels.items["block-math"], keywords: ["latex", "formula", "display", "katex", "块级公式", "独立公式", "数式"] },
{ id: "attachment", command: "upload", group: "insert", icon: FileUp, label: labels.items.attachment, keywords: ["file", "attachment", "文件", "上传", "附件"] },
{ id: "note-link", command: "note", group: "insert", icon: Link, label: labels.items["note-link"], keywords: ["link", "memo", "笔记", "引用"] },
{ id: "external-link", command: "link", group: "insert", icon: Link, label: labels.items["external-link"], keywords: ["url", "web", "链接", "网址"] },
@@ -248,6 +254,14 @@ const runSlashCommand = ({
case "divider": chain.setHorizontalRule().run(); break;
case "fold": chain.setDetails().run(); break;
case "table": chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); break;
case "inline-math":
chain.run();
window.requestAnimationFrame(() => actions.openMathFormula("inline"));
break;
case "block-math":
chain.run();
window.requestAnimationFrame(() => actions.openMathFormula("block"));
break;
case "current-date": chain.insertContent(formatCurrentDate(new Date())).run(); break;
case "current-time": chain.insertContent(formatCurrentTime(new Date())).run(); break;
case "current-date-time": chain.insertContent(formatCurrentDateTime(new Date())).run(); break;
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { shouldRetryCreatedMemoFocus } from "./editor-pane-helpers.ts";
import {
shouldInsertDroppedResourceFiles,
shouldRetryCreatedMemoFocus,
} from "./editor-pane-helpers.ts";
describe("created memo focus retry", () => {
test("keeps retrying until the editor is hydrated, editable, and focused", () => {
@@ -56,3 +59,32 @@ describe("editor document reset", () => {
expect(source).toContain("plugins: editor.state.plugins");
});
});
describe("dropped resource files", () => {
const fileTransfer = {
items: [{ kind: "file", getAsFile: () => new File(["png"], "preview.png", { type: "image/png" }) }],
files: [],
};
test("inserts clipboard or OS files", () => {
expect(shouldInsertDroppedResourceFiles({
dataTransfer: fileTransfer,
isInternalNodeDrag: false,
})).toBe(true);
});
test("does not steal a block-handle drop that Chrome tagged with a preview file", () => {
expect(shouldInsertDroppedResourceFiles({
dataTransfer: fileTransfer,
isInternalNodeDrag: true,
})).toBe(false);
});
});
describe("block handle drop wiring", () => {
test("lets ProseMirror finish an internal node drag", () => {
const source = readFileSync(new URL("../EditorPane.tsx", import.meta.url), "utf8");
expect(source).toContain("shouldInsertDroppedResourceFiles");
expect(source).toContain("isInternalNodeDrag: Boolean(view.dragging)");
});
});
@@ -188,6 +188,15 @@ export const getResourceFilesFromDataTransfer = (dataTransfer: DataTransfer | nu
return files.filter((file) => file.size > 0);
};
/** Block-handle drags set view.dragging; Chrome may also attach a preview image as a file. */
export const shouldInsertDroppedResourceFiles = ({
dataTransfer,
isInternalNodeDrag,
}: {
dataTransfer: DataTransfer | null;
isInternalNodeDrag: boolean;
}) => !isInternalNodeDrag && getResourceFilesFromDataTransfer(dataTransfer).length > 0;
export const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => {
if (status === "conflict") {
return "conflict";
@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test";
import { Editor } from "@tiptap/core";
import { createEdgeEverDocumentExtensions } from "@edgeever/shared";
import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import {
applyMathFormula,
deleteMathFormula,
resolveMathFormulaTarget,
selectedTextAsLatex,
} from "./math-formula.ts";
const createEditor = (content) => new Editor({
extensions: createEdgeEverDocumentExtensions({
mathematics: createEdgeEverMathematics(),
}),
content,
});
describe("math formula commands", () => {
test("inserts inline math at the caret", () => {
const editor = createEditor({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "ab" }] }],
});
editor.commands.setTextSelection(2);
expect(applyMathFormula(editor, { kind: "inline", latex: "x^2" })).toBe(true);
expect(editor.getJSON().content[0].content).toEqual([
{ type: "text", text: "a" },
{ type: "inlineMath", attrs: { latex: "x^2" } },
{ type: "text", text: "b" },
]);
editor.destroy();
});
test("replaces the selected text with inline math", () => {
const editor = createEditor({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "E=mc^2" }] }],
});
editor.commands.setTextSelection({ from: 1, to: 7 });
expect(selectedTextAsLatex(editor)).toBe("E=mc^2");
expect(applyMathFormula(editor, {
kind: "inline",
latex: "E=mc^2",
from: 1,
to: 7,
})).toBe(true);
expect(editor.getJSON().content[0].content).toEqual([
{ type: "inlineMath", attrs: { latex: "E=mc^2" } },
]);
editor.destroy();
});
test("updates and deletes an existing formula", () => {
const editor = createEditor({
type: "doc",
content: [{
type: "paragraph",
content: [{ type: "inlineMath", attrs: { latex: "a+b" } }],
}],
});
editor.commands.setTextSelection(1);
const target = resolveMathFormulaTarget(editor, 1);
expect(target).toEqual({ kind: "inline", pos: 1, latex: "a+b" });
expect(applyMathFormula(editor, { kind: "inline", latex: "c+d", pos: 1 })).toBe(true);
expect(editor.getJSON().content[0].content[0].attrs.latex).toBe("c+d");
expect(deleteMathFormula(editor, { kind: "inline", pos: 1 })).toBe(true);
expect(editor.getJSON().content[0].content ?? []).toEqual([]);
editor.destroy();
});
test("inserts a display formula as its own block", () => {
const editor = createEditor({
type: "doc",
content: [{ type: "paragraph" }],
});
expect(applyMathFormula(editor, { kind: "block", latex: "\\int x dx" })).toBe(true);
expect(editor.getJSON().content.some((node) => node.type === "blockMath" && node.attrs.latex === "\\int x dx")).toBe(true);
editor.destroy();
});
test("rejects empty latex", () => {
const editor = createEditor({
type: "doc",
content: [{ type: "paragraph" }],
});
expect(applyMathFormula(editor, { kind: "inline", latex: " " })).toBe(false);
editor.destroy();
});
});
@@ -0,0 +1,64 @@
import type { Editor } from "@tiptap/core";
import {
BLOCK_MATH_NODE_TYPE,
INLINE_MATH_NODE_TYPE,
} from "@edgeever/shared/mathematics";
export type MathFormulaKind = "inline" | "block";
export type MathFormulaTarget = {
kind: MathFormulaKind;
latex: string;
pos: number;
};
export type MathFormulaDraft = {
kind: MathFormulaKind;
latex: string;
pos?: number;
from?: number;
to?: number;
};
export const selectedTextAsLatex = (editor: Editor) =>
editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, " ").trim();
export const resolveMathFormulaTarget = (editor: Editor, pos?: number): MathFormulaTarget | null => {
const documentPos = pos ?? editor.state.selection.from;
const node = editor.state.doc.nodeAt(documentPos);
if (!node) return null;
if (node.type.name === INLINE_MATH_NODE_TYPE) {
return { kind: "inline", pos: documentPos, latex: String(node.attrs.latex ?? "") };
}
if (node.type.name === BLOCK_MATH_NODE_TYPE) {
return { kind: "block", pos: documentPos, latex: String(node.attrs.latex ?? "") };
}
return null;
};
export const applyMathFormula = (editor: Editor, draft: MathFormulaDraft) => {
const latex = draft.latex.trim();
if (!latex) return false;
if (typeof draft.pos === "number") {
return draft.kind === "inline"
? editor.commands.updateInlineMath({ latex, pos: draft.pos })
: editor.commands.updateBlockMath({ latex, pos: draft.pos });
}
const from = draft.from ?? editor.state.selection.from;
const to = draft.to ?? editor.state.selection.to;
const chain = editor.chain();
if (to > from) {
chain.deleteRange({ from, to });
}
return draft.kind === "inline"
? chain.insertInlineMath({ latex, pos: from }).run()
: chain.insertBlockMath({ latex, pos: from }).run();
};
export const deleteMathFormula = (editor: Editor, target: Pick<MathFormulaTarget, "kind" | "pos">) => {
return target.kind === "inline"
? editor.commands.deleteInlineMath({ pos: target.pos })
: editor.commands.deleteBlockMath({ pos: target.pos });
};
+54 -1
View File
@@ -88,7 +88,12 @@ html.dark[data-edgeever-environment="demo"] body::after {
}
.ProseMirror .tiptap-mathematics-render--editable {
cursor: text;
cursor: pointer;
border-radius: 0.2rem;
}
.ProseMirror .tiptap-mathematics-render--editable:hover {
background: rgb(22 160 110 / 0.08);
}
.ProseMirror .inline-math-error,
@@ -697,6 +702,54 @@ textarea {
font-feature-settings: "chws" 1;
}
.edgeever-block-drag-handle {
z-index: 20;
display: none;
width: 1.25rem;
height: 1.5rem;
align-items: center;
justify-content: center;
cursor: grab;
border-radius: 0.25rem;
color: #94a3b8;
}
.edgeever-block-drag-handle:hover {
background: #f1f5f9;
color: #16a06e;
}
.edgeever-block-drag-handle:active,
.edgeever-block-drag-handle[data-dragging="true"] {
cursor: grabbing;
}
@media (min-width: 1024px) {
.edgeever-block-drag-handle {
display: flex;
}
}
.ProseMirror-selectednoderange {
border-radius: 0.25rem;
background: rgb(22 160 110 / 0.06);
outline: 2px solid rgb(22 160 110 / 0.28);
}
:root.dark .edgeever-block-drag-handle {
color: #64748b;
}
:root.dark .edgeever-block-drag-handle:hover {
background: #1e293b;
color: #34d399;
}
:root.dark .ProseMirror-selectednoderange {
background: rgb(52 211 153 / 0.1);
outline-color: rgb(52 211 153 / 0.35);
}
.ProseMirror [data-type="details"] {
display: flex;
align-items: flex-start;
+24 -1
View File
@@ -25,15 +25,20 @@
"@tanstack/react-query": "latest",
"@tanstack/react-virtual": "^3.14.13",
"@tiptap/core": "3.31.0",
"@tiptap/extension-collaboration": "3.31.0",
"@tiptap/extension-details": "3.31.0",
"@tiptap/extension-drag-handle": "3.31.0",
"@tiptap/extension-drag-handle-react": "3.31.0",
"@tiptap/extension-image": "3.31.0",
"@tiptap/extension-list": "3.31.0",
"@tiptap/extension-node-range": "3.31.0",
"@tiptap/extension-placeholder": "3.31.0",
"@tiptap/extension-table": "3.31.0",
"@tiptap/pm": "3.31.0",
"@tiptap/react": "3.31.0",
"@tiptap/starter-kit": "3.31.0",
"@tiptap/suggestion": "3.31.0",
"@tiptap/y-tiptap": "3.0.7",
"ai": "^7.0.58",
"aws4fetch": "^1.0.20",
"class-variance-authority": "latest",
@@ -97,7 +102,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.78.1",
"version": "1.79.0",
"dependencies": {
"@edgeever/public-network": "workspace:*",
"croner": "^10.0.1",
@@ -1377,10 +1382,16 @@
"@tiptap/extension-code-block-lowlight": ["@tiptap/extension-code-block-lowlight@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/extension-code-block": "3.31.0", "@tiptap/pm": "3.31.0", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "sha512-fFwXqwh0CwJkQzdqgv4ObcS70m0Muq1RbAZUTQNZFqCDcu1krDJW0PWNmua7+nZdmXwOyA2PDUTAtWYFQ+srpw=="],
"@tiptap/extension-collaboration": ["@tiptap/extension-collaboration@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0", "@tiptap/y-tiptap": "^3.0.7", "yjs": "^13" } }, "sha512-VkfOhXOVK6YJx2vHrDTuaoSt9kQK1QVjpnbe2svU57fDtY3z7pUxdulzgk9ntQ7/6mjyhODugmOm0UuQKYwBxA=="],
"@tiptap/extension-details": ["@tiptap/extension-details@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/extension-text-style": "3.31.0", "@tiptap/pm": "3.31.0" } }, "sha512-j1ZDrXRPPvCoOrKrVV1e2BXFpHVsHYmikr2ORtI0+gDh/+4qruqrm7ftGic2lm77B2hzKtm2adoSSi9XL6UQew=="],
"@tiptap/extension-document": ["@tiptap/extension-document@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0" } }, "sha512-C2eDFoBlcRAb4sgbc9ZH+mrFFbjIS9VyoERGeDum3QRdqLEKU1h14Th7isHpGQzTvTywhXQxqXC64mgeSGXLsA=="],
"@tiptap/extension-drag-handle": ["@tiptap/extension-drag-handle@3.31.0", "", { "dependencies": { "@floating-ui/dom": "^1.6.13" }, "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/extension-collaboration": "3.31.0", "@tiptap/extension-node-range": "3.31.0", "@tiptap/pm": "3.31.0", "@tiptap/y-tiptap": "^3.0.7" } }, "sha512-v04z6nHLGaxC0EYJ6hhWzsl01LfuiMLseTXYEqls5nnl0nmYntPBiwHX2ZoHJ5e42qi76nVO2G06fyigUsI10w=="],
"@tiptap/extension-drag-handle-react": ["@tiptap/extension-drag-handle-react@3.31.0", "", { "peerDependencies": { "@tiptap/extension-drag-handle": "3.31.0", "@tiptap/pm": "3.31.0", "@tiptap/react": "3.31.0", "react": "^16.8 || ^17 || ^18 || ^19", "react-dom": "^16.8 || ^17 || ^18 || ^19" } }, "sha512-sDAUF5HS0aytRjhbX+jnHzkD695W9Ha4B2SkOcpM1wDSmaYbTdgkwaFh2BZPkd+Hb/J2kdBgjR+ceZE9FU6R2Q=="],
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.31.0", "", { "peerDependencies": { "@tiptap/extensions": "3.31.0" } }, "sha512-hIZOPfM0sGkAhPV69IK1zWMbWBd3wmhS0XCKBRcXUWrE2MW4kkaSyYprSFqtqFuUa3lVIEFrlME1WOEBJ2/2rg=="],
"@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.31.0", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0" } }, "sha512-NFwv1Kah3zGLmjj53Z+pA2584GdgMDClgvebJPqSunsSdHwHlFwfu+Op1u73xuEYOd6wGgCgzzDZ3V4CKwkJqg=="],
@@ -1407,6 +1418,8 @@
"@tiptap/extension-mathematics": ["@tiptap/extension-mathematics@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0", "katex": "^0.16.4 || ^0.17.0 || ^0.18.0" } }, "sha512-wr3L8zb/jGWPeYb7xvn5AUqUz/ORR6V/kFRGqmZwdM45ksU2txt06Jj16eu5uiUDdGIcLgx0QiMw6hgUyJZAQg=="],
"@tiptap/extension-node-range": ["@tiptap/extension-node-range@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0" } }, "sha512-QiBWWVdnmQHMKRKrN4UySUaqqTK2jjqZL6Jtz6xFzrrZYI/iS91Uf0J6g4NaM6dfm+8ODUBL8m7aHeYbGZam6A=="],
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.31.0", "", { "peerDependencies": { "@tiptap/extension-list": "3.31.0" } }, "sha512-KLCax7pI/3JTCkMY01n2+CXwZRPPsIVR1fvSmkFMryUPonkkMYWSUlXCuYKvF22+kM0gU0kiXsVjqsn/YFdeDg=="],
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0" } }, "sha512-78l9D/jpKvvWXrBBzYPfgqwonBY76+1MMgSovkPffIbya+jaYDJdZhIpeCZXtSBDnw8cYE/rheAeusN1QG3SVQ=="],
@@ -1435,6 +1448,8 @@
"@tiptap/suggestion": ["@tiptap/suggestion@3.31.0", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0" } }, "sha512-p/m8K4jmF+b5JdRcZ5onOKxJf3dsTcXhnV6qAjDu1xrvpY8lXZe4DXgtuAUvh5Q4Msmq6IYuMAvleR4WAkIXIQ=="],
"@tiptap/y-tiptap": ["@tiptap/y-tiptap@3.0.7", "", { "dependencies": { "lib0": "^0.2.100" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-3VG01F7i2JDghWsBZSBKi7ypMiN5UVVSyD18IwoXx7ilm0ZynrTS+XNpmgxfuiSBjdauWk7tUlP04xfM8Bw4Vw=="],
"@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
@@ -2791,6 +2806,8 @@
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
@@ -2875,6 +2892,8 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="],
@@ -4169,6 +4188,8 @@
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
@@ -4185,6 +4206,8 @@
"yet-another-react-lightbox": ["yet-another-react-lightbox@3.32.0", "", { "peerDependencies": { "@types/react": "^16 || ^17 || ^18 || ^19", "@types/react-dom": "^16 || ^17 || ^18 || ^19", "react": "^16.8.0 || ^17 || ^18 || ^19", "react-dom": "^16.8.0 || ^17 || ^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FWODOMrE07i3O5MeWRcYlcnAUk518zkUYKAe307pVW5pkey3hKMcAIWn8yMIURzGvbd3m9eghWO9CocEZmQPIg=="],
"yjs": ["yjs@13.6.32", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="],
+5
View File
@@ -137,15 +137,20 @@
"@tanstack/react-query": "latest",
"@tanstack/react-virtual": "^3.14.13",
"@tiptap/core": "3.31.0",
"@tiptap/extension-collaboration": "3.31.0",
"@tiptap/extension-details": "3.31.0",
"@tiptap/extension-drag-handle": "3.31.0",
"@tiptap/extension-drag-handle-react": "3.31.0",
"@tiptap/extension-image": "3.31.0",
"@tiptap/extension-list": "3.31.0",
"@tiptap/extension-node-range": "3.31.0",
"@tiptap/extension-placeholder": "3.31.0",
"@tiptap/extension-table": "3.31.0",
"@tiptap/pm": "3.31.0",
"@tiptap/react": "3.31.0",
"@tiptap/starter-kit": "3.31.0",
"@tiptap/suggestion": "3.31.0",
"@tiptap/y-tiptap": "3.0.7",
"ai": "^7.0.58",
"aws4fetch": "^1.0.20",
"class-variance-authority": "latest",
+18
View File
@@ -1657,6 +1657,7 @@ export const enUS = {
},
placeholder: "space for AI · / commands · @ notes",
placeholderCommands: "/ commands · @ notes",
dragHandle: "Drag block",
noteBodyAria: "Note body",
markdownSourceAria: "Markdown source",
loading: "Loading",
@@ -1956,6 +1957,8 @@ export const enUS = {
mermaidZoomOut: "Zoom out diagram",
mermaidResetZoom: "Reset diagram zoom",
mermaidCloseViewer: "Close diagram viewer",
math: "Insert formula",
blockMath: "Display formula",
horizontalRule: "Divider",
themeBlock: "Insert content block",
deleteThemeBlock: "Delete themed block",
@@ -2000,6 +2003,21 @@ export const enUS = {
errorInvalid: "Enter a valid URL, such as example.com or https://example.com.",
errorUnsupported: "This URL scheme is not allowed.",
},
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.",
inline: "Inline",
block: "Display",
latexLabel: "LaTeX",
latexPlaceholder: "E = mc^2",
preview: "Preview",
previewEmpty: "Preview appears after you enter LaTeX",
previewError: "Could not render this formula",
apply: "Apply",
remove: "Delete formula",
errorEmpty: "Enter LaTeX.",
},
noteLinkPicker: {
title: "Link a note",
searchPlaceholder: "Search notes to link",
+18
View File
@@ -1657,6 +1657,7 @@ export const ja = {
},
placeholder: "スペースで AI · / コマンド · @ ノート",
placeholderCommands: "/ コマンド · @ ノート",
dragHandle: "ブロックをドラッグ",
noteBodyAria: "ノート本文",
markdownSourceAria: "Markdown ソース",
loading: "読み込み中",
@@ -1956,6 +1957,8 @@ export const ja = {
mermaidZoomOut: "図を縮小",
mermaidResetZoom: "図のズームをリセット",
mermaidCloseViewer: "図ビューアを閉じる",
math: "数式を挿入",
blockMath: "ブロック数式",
horizontalRule: "区切り線",
themeBlock: "コンテンツブロックを挿入",
deleteThemeBlock: "テーマ付きブロックを削除",
@@ -2000,6 +2003,21 @@ export const ja = {
errorInvalid: "example.com または https://example.com のような有効な URL を入力してください。",
errorUnsupported: "この URL スキームは使えません。",
},
mathFormulaDialog: {
title: "数式を挿入",
editTitle: "数式を編集",
description: "LaTeX で数式を書きます。インラインは文中に入り、ブロックは独立した行になります。",
inline: "インライン",
block: "ブロック",
latexLabel: "LaTeX",
latexPlaceholder: "E = mc^2",
preview: "プレビュー",
previewEmpty: "LaTeX を入力するとプレビューされます",
previewError: "この数式を表示できません",
apply: "適用",
remove: "数式を削除",
errorEmpty: "LaTeX を入力してください。",
},
noteLinkPicker: {
title: "ノートをリンク",
searchPlaceholder: "リンクするノートを検索",
+18
View File
@@ -1657,6 +1657,7 @@ export const zhCN = {
},
placeholder: "空格唤起 AI · / 命令 · @ 引用",
placeholderCommands: "/ 命令 · @ 引用",
dragHandle: "拖动块",
noteBodyAria: "笔记正文",
markdownSourceAria: "Markdown 源码",
loading: "加载中",
@@ -1954,6 +1955,8 @@ export const zhCN = {
mermaidZoomOut: "缩小图表",
mermaidResetZoom: "重置图表缩放",
mermaidCloseViewer: "关闭图表查看器",
math: "插入公式",
blockMath: "块级公式",
horizontalRule: "分割线",
themeBlock: "插入内容块",
deleteThemeBlock: "删除主题化组件",
@@ -1998,6 +2001,21 @@ export const zhCN = {
errorInvalid: "请输入有效网址,例如 example.com 或 https://example.com。",
errorUnsupported: "不支持此网址协议。",
},
mathFormulaDialog: {
title: "插入公式",
editTitle: "编辑公式",
description: "用 LaTeX 编写公式。行内公式会出现在句子中,块级公式单独占一行。",
inline: "行内",
block: "块级",
latexLabel: "LaTeX",
latexPlaceholder: "E = mc^2",
preview: "预览",
previewEmpty: "输入 LaTeX 后显示预览",
previewError: "无法渲染该公式",
apply: "应用",
remove: "删除公式",
errorEmpty: "请输入 LaTeX。",
},
noteLinkPicker: {
title: "引用笔记",
searchPlaceholder: "搜索要引用的笔记",
+11 -3
View File
@@ -1,4 +1,5 @@
import { BlockMath, InlineMath } from "@tiptap/extension-mathematics";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
BLOCK_MATH_NODE_TYPE,
edgeEverBlockMathMarkdownTokenizer,
@@ -11,6 +12,13 @@ export {
INLINE_MATH_NODE_TYPE,
} from "./mathematics-markdown";
export type EdgeEverMathematicsClickHandler = (node: ProseMirrorNode, pos: number) => void;
export type CreateEdgeEverMathematicsOptions = {
onInlineClick?: EdgeEverMathematicsClickHandler;
onBlockClick?: EdgeEverMathematicsClickHandler;
};
const EdgeEverInlineMath = InlineMath.extend({
markdownTokenizer: edgeEverInlineMathMarkdownTokenizer,
});
@@ -26,7 +34,7 @@ const katexOptions = {
};
/** Fresh extension instances for each TipTap editor or Markdown manager. */
export const createEdgeEverMathematics = () => [
EdgeEverBlockMath.configure({ katexOptions }),
EdgeEverInlineMath.configure({ katexOptions }),
export const createEdgeEverMathematics = (options: CreateEdgeEverMathematicsOptions = {}) => [
EdgeEverBlockMath.configure({ katexOptions, onClick: options.onBlockClick }),
EdgeEverInlineMath.configure({ katexOptions, onClick: options.onInlineClick }),
];