fix(markdown): keep dollar amounts literal in editor (#4084)

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-06-13 02:14:41 +08:00
committed by GitHub
parent f415099c4a
commit 04a0677704
2 changed files with 90 additions and 35 deletions

View File

@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
import { BlockMathExtension, InlineMathExtension } from "./math";
const FINANCE_TEXT =
"Revenue increased from $100~$120 in Q1 to $140~$160 in Q2.";
interface JsonNode {
type?: string;
attrs?: Record<string, unknown>;
content?: JsonNode[];
}
function makeEditor() {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [
StarterKit,
BlockMathExtension,
InlineMathExtension,
Markdown.configure({ indentation: { style: "space", size: 3 } }),
],
});
}
function findAll(node: JsonNode, type: string, acc: JsonNode[] = []): JsonNode[] {
if (node.type === type) acc.push(node);
for (const child of node.content ?? []) findAll(child, type, acc);
return acc;
}
function typeText(editor: Editor, text: string) {
for (const ch of text) {
const { from, to } = editor.state.selection;
const handled = editor.view.someProp("handleTextInput", (handler) =>
handler(editor.view, from, to, ch, () => editor.state.tr),
);
if (!handled) editor.view.dispatch(editor.state.tr.insertText(ch, from, to));
}
}
let editor: Editor | null = null;
afterEach(() => {
editor?.destroy();
editor = null;
document.body.innerHTML = "";
});
describe("math editor extension", () => {
it("keeps typed single-dollar amounts as literal text", () => {
editor = makeEditor();
typeText(editor, FINANCE_TEXT);
expect(findAll(editor.getJSON() as JsonNode, "inlineMath")).toHaveLength(0);
expect(editor.getText()).toBe(FINANCE_TEXT);
expect(editor.getMarkdown().trim()).toBe(FINANCE_TEXT);
});
it("parses single-dollar markdown as literal text", () => {
editor = makeEditor();
editor.commands.setContent(FINANCE_TEXT, { contentType: "markdown" });
expect(findAll(editor.getJSON() as JsonNode, "inlineMath")).toHaveLength(0);
expect(editor.getText()).toBe(FINANCE_TEXT);
expect(editor.getMarkdown().trim()).toBe(FINANCE_TEXT);
});
it("still parses explicit display math blocks", () => {
editor = makeEditor();
const markdown = "$$\nx^2 + y^2 = z^2\n$$";
editor.commands.setContent(markdown, { contentType: "markdown" });
const blocks = findAll(editor.getJSON() as JsonNode, "blockMath");
expect(blocks).toHaveLength(1);
expect(blocks[0]?.attrs?.expression).toBe("x^2 + y^2 = z^2");
expect(editor.getMarkdown().trim()).toBe(markdown);
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import katex from "katex";
import { Node, mergeAttributes, nodeInputRule } from "@tiptap/core";
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
@@ -82,45 +82,14 @@ export const InlineMathExtension = Node.create({
];
},
markdownTokenizer: {
name: "inlineMath",
level: "inline" as const,
start(src: string) {
return src.indexOf("$");
},
tokenize(src: string) {
if (!src.startsWith("$") || src.startsWith("$$")) return undefined;
const match = src.match(/^\$((?:\\.|[^$\\\n])+?)\$/);
if (!match) return undefined;
return {
type: "inlineMath",
raw: match[0],
attributes: { expression: match[1] },
};
},
},
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("inlineMath", token.attributes);
},
// Single-dollar inline math is intentionally not parsed from Markdown and has
// no typing input rule. Dollar amounts like `$100~$120` must stay literal;
// users should write explicit `$$...$$` blocks for math.
renderMarkdown: (node: any) => {
const expression = String(node.attrs?.expression ?? "");
return `$${expression}$`;
},
addInputRules() {
return [
nodeInputRule({
find: /\$(?:\\.|[^$\\\n])+\$$/,
type: this.type,
getAttributes: (match) => ({
expression: match[0].slice(1, -1),
}),
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(InlineMathView);
},