From 04a0677704af2f5e3c2bd05032dcbbcefc99dda5 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:14:41 +0800 Subject: [PATCH] fix(markdown): keep dollar amounts literal in editor (#4084) Co-authored-by: J Co-authored-by: multica-agent --- packages/views/editor/extensions/math.test.ts | 86 +++++++++++++++++++ packages/views/editor/extensions/math.tsx | 39 +-------- 2 files changed, 90 insertions(+), 35 deletions(-) create mode 100644 packages/views/editor/extensions/math.test.ts diff --git a/packages/views/editor/extensions/math.test.ts b/packages/views/editor/extensions/math.test.ts new file mode 100644 index 000000000..9ea5c872c --- /dev/null +++ b/packages/views/editor/extensions/math.test.ts @@ -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; + 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); + }); +}); diff --git a/packages/views/editor/extensions/math.tsx b/packages/views/editor/extensions/math.tsx index d8820ef22..b24d6d217 100644 --- a/packages/views/editor/extensions/math.tsx +++ b/packages/views/editor/extensions/math.tsx @@ -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); },