mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
86
packages/views/editor/extensions/math.test.ts
Normal file
86
packages/views/editor/extensions/math.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user