From 1841cccee81edf6557d271797ca62fdd5af10529 Mon Sep 17 00:00:00 2001 From: sugoidesune Date: Fri, 21 Aug 2026 05:11:16 +0200 Subject: [PATCH] feat(web): add LaTeX math rendering support to ChatMarkdown Support inline LaTeX \(...\) and block display LaTeX \[...\] / 79210...79210 delimiters in ChatMarkdown without mutating code blocks or ambient prose. Includes clean markdown math preprocessing, KaTeX styling and overflow containment, and markdown clipboard copy serialization. --- apps/web/package.json | 7 + apps/web/src/components/ChatMarkdown.test.tsx | 528 +++++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 159 +++++- apps/web/src/index.css | 21 +- apps/web/src/markdown-clipboard.test.ts | 87 ++- apps/web/src/markdown-clipboard.ts | 16 + pnpm-lock.yaml | 118 +++- 7 files changed, 920 insertions(+), 16 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 598feaec0ce9..9106dba094cf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,15 +37,18 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", + "katex": "^0.16.47", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" }, @@ -58,14 +61,18 @@ "@types/babel__core": "^7.20.5", "@types/compression": "^1.8.1", "@types/culori": "^4.0.1", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", + "mdast-util-math": "^3.0.0", "msw": "2.12.11", "tailwindcss": "^4.0.0", + "unified": "^11.0.5", "vite": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 9499ee5a6915..8b175d35d991 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,7 @@ +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { orderedListGutterStyle } from "./ChatMarkdown"; +import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown"; describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -34,3 +35,528 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); }); }); + +describe("LaTeX / Math rendering in ChatMarkdown", () => { + it("renders inline math with KaTeX markup", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("katex"); + expect(html).toContain("katex-html"); + expect(html).toContain("E"); + }); + + it("renders repeated inline LaTeX delimiters in prose", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("katex"); + expect(html.match(/class="katex"/g)).toHaveLength(5); + expect(html).toContain("i=\\sqrt{-1}"); + expect(html).toContain("i^2=-1"); + }); + + it("renders block display math with KaTeX markup", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("katex-display"); + expect(html).toContain("katex-html"); + }); + + it("renders math when sanitized raw HTML parsing is enabled", () => { + const html = renderToStaticMarkup( + Formula: \(x^2\)`} + cwd={undefined} + parseRawHtml + />, + ); + expect(html).toContain("katex-html"); + }); + + it("renders LaTeX bracket notations \\[ ... \\] and \\( ... \\)", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("katex-display"); + expect(html).toContain("katex-html"); + expect(html).toContain("1+i"); + expect(html).toContain("2-i"); + }); + + it("does not mutate brackets inside code blocks", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("\\[ \\frac{a}{b} \\]"); + }); + + it.each([ + "~~~ts\nconst x = \\\\(notMath\\\\);\n~~~", + " const x = \\\\(notMath\\\\);", + "Use ``\\(notMath\\)`` here.", + ])("does not mutate math delimiters in another code form", (text) => { + const html = renderToStaticMarkup(); + expect(html).toContain("notMath"); + expect(html).not.toContain("katex"); + }); + + it("leaves paired currency amounts intact without triggering math rendering", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("$5 today and $10 tomorrow."); + expect(html).not.toContain("katex"); + }); + + it.each([ + ["blockquotes", "> Quote: \\(E = mc^2\\)", "katex"], + ["display math in blockquotes", "> \\[\n> E = mc^2\n> \\]", "katex-display"], + ["list items", "- Item with \\(x + y\\)", "katex"], + ["display math in list items", "- Item:\n \\[\n x = 1\n \\]", "katex-display"], + ["emphasis", "**Bold \\(x^2\\)** and *italic \\(y^2\\)*", "katex"], + ["links", "[Formula \\(x^2\\)](https://example.com)", "katex"], + ["tables", "| Formula |\n| --- |\n| \\(x^2 + y^2\\) |", "katex"], + ["footnotes", "Formula[^1]\n\n[^1]: Here is \\(x^2\\)", "katex"], + ])("renders math nested inside %s", (_container, markdown, expectedClass) => { + const html = renderToStaticMarkup(); + expect(html).toContain(expectedClass); + expect(html).toContain("katex-html"); + }); + + it("renders multiline aligned equations and cases with row breaks in display math", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("katex-display"); + expect(html).toContain("aligned"); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("handles formulas with nested parentheses and brackets without delimiter truncation", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("katex-html"); + expect(html).toContain("katex-display"); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("renders formulas containing markdown-sensitive characters like underscores and asterisks", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("katex-html"); + expect(html.match(/class="katex"/g)).toHaveLength(2); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("renders multiple display equations interspersed with text in a single block", () => { + const html = renderToStaticMarkup( + , + ); + const displayMatches = html.match(/class="katex-display"/g); + expect(displayMatches).toHaveLength(2); + expect(html).toContain("First equation:"); + expect(html).toContain("and second equation:"); + expect(html).toContain("with conclusion."); + }); + + it("leaves unclosed delimiters as literal text without crashing or rendering invalid math", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("This has an unclosed delimiter ( x^2 + y^2 and continues as text."); + expect(html).not.toContain("katex-error"); + }); + + it("does not treat plain bracketed text or array indexing as math", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("arr[i]"); + expect(html).toContain("TODO: do not convert this plain bracketed text"); + expect(html).not.toContain("katex"); + }); + + it("handles empty math delimiters gracefully", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("renders complex nested structures with both inline and display math in blockquotes and lists", () => { + const html = renderToStaticMarkup( + Quote starting with inline \(a=1\): +> \[ +> \begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix} +> \] +> and ending with \(b=2\). + +1. List item 1 with \(x_i\): + \[ + y_i = x_i^2 + 1 + \] +2. List item 2 with \(\sum_{k=1}^n k\)`} + cwd={undefined} + />, + ); + expect(html).toContain("katex-display"); + expect(html).toContain("katex-html"); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("renders complex calculus, limits, summations, and vectors without errors", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("katex-display"); + expect(html.match(/class="katex"/g)?.length).toBeGreaterThanOrEqual(4); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("leaves standard backslashes in prose untouched without false math triggering", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("C:\\Users\\user\\Desktop\\file.txt"); + expect(html).toContain("\\n, \\t, \\r"); + expect(html).toContain("\\d+ and \\w+"); + expect(html).not.toContain("katex"); + }); + + it("correctly handles interleaved inline code and math on the same line", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("x = 2"); + expect(html).toContain("data-inline-code"); + expect(html).toContain("katex-html"); + expect(html).toContain("f(x)"); + }); + + it("renders math inside GFM task list items", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('type="checkbox"'); + expect(html).toContain("katex-html"); + expect(html).toContain("katex-display"); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("strips blockquote continuation markers from display math without rendering stray > relation", () => { + const html = renderToStaticMarkup( + \\[ +> E = mc^2 +> \\]`} + cwd={undefined} + />, + ); + expect(html).toContain("katex-display"); + expect(html).toContain('E = mc^2'); + expect(html).not.toContain(">"); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("renders display math inside mixed-phrasing paragraphs alongside emphasis and links", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Note:"); + expect(html).toContain("katex-display"); + expect(html).toContain("E = mc^2"); + expect(html).toContain('href="https://example.com"'); + expect(html).not.toContain("\\["); + expect(html).not.toContain("\\]"); + }); + + it("preserves markdown escape resolutions in text adjacent to math", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Costs $100 for"); + expect(html).toContain("katex-html"); + expect(html).toContain("with *not italic* and _not underline_."); + expect(html).not.toContain("\\$"); + expect(html).not.toContain("\\*"); + expect(html).not.toContain("\\_"); + }); + + it("preserves valid > operators in display math outside blockquotes", () => { + const html = renderToStaticMarkup( + b +\\]`} + cwd={undefined} + />, + ); + expect(html).toContain("katex-display"); + expect(html).toContain(">"); + expect(html).toContain("a"); + expect(html).toContain("b"); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("preserves valid > operators in display math inside blockquotes after stripping quote prefix", () => { + const html = renderToStaticMarkup( + \\[ +> a > b +> \\]`} + cwd={undefined} + />, + ); + expect(html).toContain("katex-display"); + expect(html).toContain(">"); + expect(html).toContain("a"); + expect(html).toContain("b"); + expect(html).not.toContain("KaTeX parse error"); + }); + + it("does not produce block display math elements inside inline containers like strong or links", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Result:"); + expect(html).toContain('href="https://example.com"'); + expect(html).not.toContain("katex-display"); + }); + + it("does not render escaped literal delimiters as math when adjacent to real math in a paragraph", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(String.raw`\(literal\)`); + expect(html).not.toContain('literal'); + expect(html).toContain('x^2'); + }); + + it("decodes HTML character references in text adjacent to math", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("AT&T"); + expect(html).not.toContain("&"); + expect(html).toContain("© 2026"); + expect(html).not.toContain("©"); + expect(html).toContain("& / & symbol."); + expect(html).not.toContain("&"); + expect(html).not.toContain("&"); + }); + + it("strips blockquote continuation markers from prose surrounding inline math", () => { + const html = renderToStaticMarkup( + Text \\(x\\) and more +> next line`} + cwd={undefined} + />, + ); + expect(html).toContain("
"); + expect(html).toContain("katex-html"); + expect(html).toContain("and more\nnext line"); + expect(html).not.toContain(">"); + }); + + it("strips blockquote continuation markers from prose surrounding display math", () => { + const html = renderToStaticMarkup( + Text before +> \\[ E = mc^2 \\] +> text after +> next line`} + cwd={undefined} + />, + ); + expect(html).toContain("
"); + expect(html).toContain("katex-display"); + expect(html).toContain("text after\nnext line"); + expect(html).not.toContain(">"); + }); + + it("renders complex multi-line blockquote with inline math, display math, prose, and character entities without corruption", () => { + const html = renderToStaticMarkup( + AT&T © 2026 with \\(x_1 + x_2\\) equation +> continuation line before display math +> \\[ \\frac{a}{b} = c \\] +> and final prose with "quoted" text on line 4.`} + cwd={undefined} + />, + ); + expect(html).toContain("
"); + expect(html).toContain("AT&T"); + expect(html).toContain("© 2026"); + expect(html).toContain("katex-html"); + expect(html).toContain("katex-display"); + expect(html).toContain(""quoted""); + expect(html).toContain("continuation line before display math"); + expect(html).toContain("and final prose with"); + expect(html).not.toContain(">"); + expect(html).not.toContain("&amp;"); + expect(html).not.toContain("&copy;"); + expect(html).not.toContain("&quot;"); + }); + + it("handles multi-line inline backticks and tilde code fences without corrupting math or dollar signs", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("with \\(x\\) and $100"); + expect(html).toContain("echo "$HOME and $100 and \\[x\\]""); + expect(html).toContain("katex-html"); + expect(html).toContain('y^2'); + }); + + it("correctly handles backslash parity (odd vs even backslashes) before math delimiters and dollar signs", () => { + const html = renderToStaticMarkup( + , + ); + // Even backslash \\( remains literal \(literal\) + expect(html).toContain(String.raw`\(literal\)`); + expect(html).not.toContain('literal'); + // Odd backslashes \\\( renders literal \ plus math for \(x^2\) + expect(html).toContain('x^2'); + // \\$100 renders as \$100 + expect(html).toContain("$100"); + }); + + it("preserves shell variables and multiple ambient dollar signs without triggering false math", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("$HOME"); + expect(html).toContain("$PATH"); + expect(html).toContain("$1"); + expect(html).toContain("$?"); + expect(html).toContain("$50"); + expect(html).toContain("$100"); + expect(html).toContain('x^2'); + }); + + it("handles empty inline and display delimiters gracefully without crashing or invalid display math", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Empty inline"); + expect(html).toContain("empty display"); + expect(html).toContain('x^2'); + expect(html).not.toContain("KaTeX parse error"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c3e1c5288da7..ca46a0159837 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -36,13 +36,14 @@ import React, { useState, type ReactNode, } from "react"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; -import ReactMarkdown from "react-markdown"; -import { defaultUrlTransform } from "react-markdown"; +import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; import { remarkGithubAlerts } from "../markdown-github-alerts"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; @@ -175,12 +176,147 @@ export function orderedListGutterStyle( return { "--list-gutter": `${digits + 1}ch` }; } +export function preprocessLatexDelimiters(text: string): string { + if (!text || (!text.includes("\\(") && !text.includes("\\[") && !text.includes("$"))) { + return text; + } + + // Match fenced code blocks (``` or ~~~ with >= 3 chars), or inline code (`+) + const codeRegex = + /(?:^|\n)([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n\1\2[ \t]*(?=\n|$)|$)|`+[^`]+`+/g; + + let result = ""; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = codeRegex.exec(text)) !== null) { + const textChunk = text.slice(lastIndex, match.index); + result += transformLatexDelimitersInText(textChunk); + result += match[0]; + lastIndex = codeRegex.lastIndex; + } + + result += transformLatexDelimitersInText(text.slice(lastIndex)); + return result; +} + +function countPrecedingBackslashes(str: string, index: number): number { + let count = 0; + for (let k = index - 1; k >= 0 && str.charCodeAt(k) === 92; k--) { + count++; + } + return count; +} + +function findClosingDelimiter(str: string, startIdx: number, delimiter: string): number { + for (let j = startIdx; j < str.length; j++) { + if (str.startsWith(delimiter, j)) { + if (countPrecedingBackslashes(str, j) % 2 === 0) { + return j; + } + } + } + return -1; +} + +function transformLatexDelimitersInText(raw: string): string { + if (!raw) return ""; + + let out = ""; + let i = 0; + + while (i < raw.length) { + // Check for display math $$...$$ + if (raw.startsWith("$$", i)) { + const closeIdx = raw.indexOf("$$", i + 2); + if (closeIdx !== -1) { + out += raw.slice(i, closeIdx + 2); + i = closeIdx + 2; + continue; + } + } + + const isEscaped = countPrecedingBackslashes(raw, i) % 2 === 1; + + // Check for \[...\] + if (raw.startsWith("\\[", i) && !isEscaped) { + const closeIdx = findClosingDelimiter(raw, i + 2, "\\]"); + if (closeIdx !== -1) { + const formula = raw.slice(i + 2, closeIdx); + if (formula.trim() === "") { + out += raw.slice(i, closeIdx + 2); + i = closeIdx + 2; + continue; + } + + const lineStart = Math.max(0, raw.lastIndexOf("\n", i) + 1); + const linePrefix = raw.slice(lineStart, i); + const isBlockquote = /^[ \t]*>[ \t]?/.test(linePrefix); + + if (isBlockquote) { + const cleanedFormula = formula.replace(/\n[ \t]*>[ \t]?/g, "\n").trim(); + const blockLines = cleanedFormula + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + out += `\n>\n> $$\n${blockLines}\n> $$\n>\n`; + } else { + out += "\n\n$$\n" + formula.trim() + "\n$$\n\n"; + } + i = closeIdx + 2; + continue; + } + } + + // Check for \(...\) + if (raw.startsWith("\\(", i) && !isEscaped) { + const closeIdx = findClosingDelimiter(raw, i + 2, "\\)"); + if (closeIdx !== -1) { + let formula = raw.slice(i + 2, closeIdx); + if (formula.trim() === "") { + out += raw.slice(i, closeIdx + 2); + i = closeIdx + 2; + continue; + } + + const lineStart = Math.max(0, raw.lastIndexOf("\n", i) + 1); + const linePrefix = raw.slice(lineStart, i); + const isBlockquote = /^[ \t]*>[ \t]?/.test(linePrefix); + + if (isBlockquote) { + formula = formula.replace(/\n[ \t]*>[ \t]?/g, "\n"); + } + out += "$" + formula + "$"; + i = closeIdx + 2; + continue; + } + } + + // Escape unescaped $ that is not math delimiter so remark-math does not parse currency/variables + if (raw[i] === "$" && !isEscaped) { + out += "\\$"; + i++; + continue; + } + + out += raw[i]; + i++; + } + + return out; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), - code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], + code: [ + ...(defaultSchema.attributes?.code ?? []), + "dataCodeMeta", + "dataInlineCode", + ["className", "language-math", "math-inline", "math-display"], + ], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], }, protocols: { @@ -191,6 +327,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, + [remarkMath, { singleDollarTextMath: true }], remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, @@ -199,9 +336,10 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, + [remarkMath, { singleDollarTextMath: true }], + remarkBreaks, remarkGithubAlerts, remarkNormalizeListItemIndentation, - remarkBreaks, remarkPreserveCodeMeta, remarkTagInlineCode, ] satisfies NonNullable; @@ -209,8 +347,13 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], + rehypeKatex, ] satisfies NonNullable; +const CHAT_MARKDOWN_REHYPE_PLUGINS_WITHOUT_RAW = [rehypeKatex] satisfies NonNullable< + ReactMarkdownOptions["rehypePlugins"] +>; + /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ const GITHUB_ALERT_PRESENTATIONS: Record< string, @@ -1785,6 +1928,8 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + const preprocessedText = useMemo(() => preprocessLatexDelimiters(text), [text]); + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. @@ -1800,12 +1945,14 @@ function ChatMarkdown({ remarkPlugins={ lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS } - rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined} + rehypePlugins={ + parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : CHAT_MARKDOWN_REHYPE_PLUGINS_WITHOUT_RAW + } skipHtml={false} components={markdownComponents} urlTransform={markdownUrlTransform} > - {text} + {preprocessedText} ); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 71f9443f7f62..17bd44a03890 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "katex/dist/katex.min.css"; @custom-variant dark (&:is(.dark, .dark *)); @custom-variant light (&:not(.dark, .dark *)); @@ -1991,7 +1992,8 @@ code { .chat-markdown ol, .chat-markdown blockquote, .chat-markdown pre, -.chat-markdown .chat-markdown-table-container { +.chat-markdown .chat-markdown-table-container, +.chat-markdown .katex-display { margin: 0.65rem 0; } @@ -2179,6 +2181,23 @@ code { background: color-mix(in srgb, var(--border) 78%, transparent); } +.chat-markdown .katex-display { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; +} + +.chat-markdown .katex-display::-webkit-scrollbar { + height: 7px; +} + +.chat-markdown .katex-display::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--border) 78%, transparent); +} + .chat-markdown .chat-markdown-codeblock-header, .chat-markdown .chat-markdown-chrome-action { color: color-mix(in srgb, var(--foreground) 72%, transparent); diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 7265e8b60430..4b979eb224c2 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -19,6 +19,8 @@ class FakeElement { contains: (name: string) => this.classNames.includes(name), }; + private readonly attributes = new Map(); + constructor( readonly tagName: string, private readonly classNames: ReadonlyArray = [], @@ -37,12 +39,43 @@ class FakeElement { return this; } - getAttribute(): string | null { - return null; + setAttribute(name: string, value: string): this { + this.attributes.set(name, value); + return this; } - hasAttribute(): boolean { - return false; + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + hasAttribute(name: string): boolean { + return this.attributes.has(name); + } + + querySelector(selector: string): FakeElement | null { + for (const child of this.childNodes) { + if (child.nodeType === ELEMENT_NODE) { + const el = child as FakeElement; + if (selector === "annotation" && el.tagName === "ANNOTATION") return el; + if ( + selector === 'annotation[encoding="application/x-tex"]' && + el.tagName === "ANNOTATION" && + el.getAttribute("encoding") === "application/x-tex" + ) { + return el; + } + if ( + selector === 'math[display="block"]' && + el.tagName === "MATH" && + el.getAttribute("display") === "block" + ) { + return el; + } + const found = el.querySelector(selector); + if (found) return found; + } + } + return null; } } @@ -92,4 +125,50 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + + it("serializes inline KaTeX math back to LaTeX delimiters", () => { + const mathml = new FakeElement("SPAN", ["katex-mathml"]).append( + new FakeElement("MATH").append( + new FakeElement("SEMANTICS").append( + new FakeElement("MROW").append(new FakeText("E=mc2")), + new FakeElement("ANNOTATION") + .setAttribute("encoding", "application/x-tex") + .append(new FakeText("E = mc^2")), + ), + ), + ); + const katexHtml = new FakeElement("SPAN", ["katex-html"]) + .setAttribute("aria-hidden", "true") + .append(new FakeText("E = mc2")); + const katex = new FakeElement("SPAN", ["katex"]).append(mathml, katexHtml); + const paragraph = new FakeElement("P").append( + new FakeText("The formula is "), + katex, + new FakeText(" in physics."), + ); + const container = new FakeElement("DIV").append(paragraph); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "The formula is \\(E = mc^2\\) in physics.", + ); + }); + + it("serializes display KaTeX math to block delimiters", () => { + const mathml = new FakeElement("SPAN", ["katex-mathml"]).append( + new FakeElement("MATH") + .setAttribute("display", "block") + .append( + new FakeElement("SEMANTICS").append( + new FakeElement("ANNOTATION") + .setAttribute("encoding", "application/x-tex") + .append(new FakeText("\\frac{a}{b} = c")), + ), + ), + ); + const katex = new FakeElement("SPAN", ["katex"]).append(mathml); + const display = new FakeElement("DIV", ["katex-display"]).append(katex); + const container = new FakeElement("DIV").append(display); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("$$\n\\frac{a}{b} = c\n$$"); + }); }); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 069d161a188c..750a2a114077 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -186,6 +186,19 @@ function serializeChildren(node: Node): string { return out; } +function serializeKatex(element: Element): string { + const annotation = + element.querySelector('annotation[encoding="application/x-tex"]') ?? + element.querySelector("annotation"); + const tex = annotation?.textContent?.trim() ?? ""; + if (!tex) return ""; + const isDisplay = + element.classList.contains("katex-display") || + Boolean(element.closest?.(".katex-display")) || + element.querySelector('math[display="block"]') !== null; + return isDisplay ? `\n\n$$\n${tex}\n$$\n\n` : `\\(${tex}\\)`; +} + function serializeNode(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent ?? ""; @@ -201,6 +214,9 @@ function serializeNode(node: Node): string { } const markdownCopy = element.getAttribute("data-markdown-copy"); if (markdownCopy !== null) return markdownCopy; + if (element.classList.contains("katex-display") || element.classList.contains("katex")) { + return serializeKatex(element); + } if (isSkippedElement(element)) return ""; const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f66f69b87f0..947c53ae3ee7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -591,6 +591,9 @@ importers: jszip: specifier: 3.10.1 version: 3.10.1 + katex: + specifier: ^0.16.47 + version: 0.16.47 lexical: specifier: ^0.41.0 version: 0.41.0 @@ -606,6 +609,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.16)(react@19.2.6) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -618,6 +624,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 tailwind-merge: specifier: ^3.4.0 version: 3.6.0 @@ -649,6 +658,12 @@ importers: '@types/culori': specifier: ^4.0.1 version: 4.0.1 + '@types/hast': + specifier: ^3.0.4 + version: 3.0.4 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/react': specifier: ~19.2.14 version: 19.2.16 @@ -667,12 +682,18 @@ importers: compression: specifier: ^1.8.1 version: 1.8.1 + mdast-util-math: + specifier: ^3.0.0 + version: 3.0.0 msw: specifier: 2.12.11 version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) tailwindcss: specifier: ^4.0.0 version: 4.3.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' @@ -4866,6 +4887,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -5184,10 +5208,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} @@ -5902,6 +5928,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -7146,6 +7176,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -7552,6 +7588,10 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7940,6 +7980,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -8076,6 +8119,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -9194,6 +9240,9 @@ packages: resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} hasBin: true + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} @@ -9215,6 +9264,9 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -15051,6 +15103,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 24.12.4 @@ -16241,6 +16295,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -17772,6 +17828,19 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -17795,7 +17864,6 @@ snapshots: hast-util-is-element@3.0.0: dependencies: '@types/hast': 3.0.4 - optional: true hast-util-parse-selector@4.0.0: dependencies: @@ -17873,7 +17941,6 @@ snapshots: '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 - optional: true hast-util-whitespace@3.0.0: dependencies: @@ -18226,6 +18293,10 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -18592,6 +18663,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -18938,6 +19021,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -20297,6 +20390,16 @@ snapshots: dependencies: jsesc: 3.1.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.47 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + rehype-parse@9.0.1: dependencies: '@types/hast': 3.0.4 @@ -20344,6 +20447,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -21195,7 +21307,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 - optional: true unist-util-is@6.0.1: dependencies: @@ -21215,7 +21326,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 unist-util-visit: 5.1.0 - optional: true unist-util-stringify-position@4.0.0: dependencies: