-
-
Notifications
You must be signed in to change notification settings - Fork 368
feat(messages): add optional LaTeX math rendering #587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
1db15a4
d6339dd
9be38ee
2d48c9e
26ec05c
43c429c
3343a39
a930fea
eb9be12
ec7bd07
e2bb038
2eff724
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| import { useEffect, useRef, useState, type ReactNode, type MouseEvent } from "react"; | ||
| import ReactMarkdown, { type Components } from "react-markdown"; | ||
| import rehypeKatex from "rehype-katex"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import remarkMath from "remark-math"; | ||
| import { openUrl } from "@tauri-apps/plugin-opener"; | ||
| import { | ||
| describeFileTarget, | ||
|
|
@@ -20,6 +22,7 @@ type MarkdownProps = { | |
| codeBlock?: boolean; | ||
| codeBlockStyle?: "default" | "message"; | ||
| codeBlockCopyUseModifier?: boolean; | ||
| enableMathRendering?: boolean; | ||
| showFilePath?: boolean; | ||
| workspacePath?: string | null; | ||
| onOpenFileLink?: (path: ParsedFileLocation) => void; | ||
|
|
@@ -296,6 +299,120 @@ function normalizeListIndentation(value: string) { | |
| return normalized.join("\n"); | ||
| } | ||
|
|
||
| type MarkdownFenceState = { | ||
| marker: "`" | "~"; | ||
| length: number; | ||
| }; | ||
|
|
||
| const MARKDOWN_FENCE_OPENER_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/; | ||
| const MARKDOWN_FENCE_CLOSER_PATTERN = /^ {0,3}(`{3,}|~{3,})[ \t]*$/; | ||
| const INLINE_CODE_PLACEHOLDER_PREFIX = "\u0000CODExINLINECODE"; | ||
| const INLINE_CODE_PLACEHOLDER_SUFFIX = "\u0000"; | ||
| const INLINE_CODE_PATTERN = /(`+)([\s\S]*?)\1/g; | ||
|
|
||
| function parseFenceOpener(line: string): MarkdownFenceState | null { | ||
| const match = line.match(MARKDOWN_FENCE_OPENER_PATTERN); | ||
| if (!match) { | ||
| return null; | ||
| } | ||
| const sequence = match[1]; | ||
| const marker = sequence[0]; | ||
| if (marker !== "`" && marker !== "~") { | ||
| return null; | ||
| } | ||
| return { | ||
| marker, | ||
| length: sequence.length, | ||
| }; | ||
| } | ||
|
|
||
| function isFenceCloser(line: string, activeFence: MarkdownFenceState) { | ||
| const match = line.match(MARKDOWN_FENCE_CLOSER_PATTERN); | ||
| if (!match) { | ||
| return false; | ||
| } | ||
| const sequence = match[1]; | ||
| return sequence[0] === activeFence.marker && sequence.length >= activeFence.length; | ||
| } | ||
|
|
||
| function normalizeLatexMathDelimitersInChunk(value: string) { | ||
| const inlineCodeSpans: string[] = []; | ||
| const withMaskedInlineCode = value.replace(INLINE_CODE_PATTERN, (match) => { | ||
| const index = inlineCodeSpans.length; | ||
| inlineCodeSpans.push(match); | ||
| return `${INLINE_CODE_PLACEHOLDER_PREFIX}${index}${INLINE_CODE_PLACEHOLDER_SUFFIX}`; | ||
| }); | ||
|
|
||
| const withBlockMath = withMaskedInlineCode.replace( | ||
| /\\\[([\s\S]*?)\\\]/g, | ||
| (match, body: string) => { | ||
| const trimmed = body.trim(); | ||
| if (!trimmed) { | ||
| return match; | ||
| } | ||
| return `$$\n${trimmed}\n$$`; | ||
| }, | ||
| ); | ||
| const withInlineMath = withBlockMath.replace( | ||
| /\\\(([\s\S]*?)\\\)/g, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With math rendering enabled, Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks. I pushed a follow-up fix that addresses this. What changed:
Tests added/updated:
So this should close both concerns:
|
||
| (match, body: string) => { | ||
| const trimmed = body.trim(); | ||
| if (!trimmed) { | ||
| return match; | ||
| } | ||
| return `$${trimmed}$`; | ||
| }, | ||
| ); | ||
|
|
||
| return withInlineMath.replace( | ||
| new RegExp( | ||
| `${INLINE_CODE_PLACEHOLDER_PREFIX}(\\d+)${INLINE_CODE_PLACEHOLDER_SUFFIX}`, | ||
| "g", | ||
| ), | ||
| (_match, indexString: string) => { | ||
| const index = Number(indexString); | ||
| return inlineCodeSpans[index] ?? _match; | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| function normalizeLatexMathDelimiters(value: string) { | ||
| const lines = value.split(/\r?\n/); | ||
| const output: string[] = []; | ||
| let activeFence: MarkdownFenceState | null = null; | ||
| let nonFenceChunk: string[] = []; | ||
|
|
||
| const flushNonFenceChunk = () => { | ||
| if (nonFenceChunk.length === 0) { | ||
| return; | ||
| } | ||
| output.push(normalizeLatexMathDelimitersInChunk(nonFenceChunk.join("\n"))); | ||
| nonFenceChunk = []; | ||
| }; | ||
|
|
||
| for (const line of lines) { | ||
| if (activeFence) { | ||
| output.push(line); | ||
| if (isFenceCloser(line, activeFence)) { | ||
| activeFence = null; | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| const fenceOpener = parseFenceOpener(line); | ||
| if (fenceOpener) { | ||
| flushNonFenceChunk(); | ||
| activeFence = fenceOpener; | ||
| output.push(line); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| continue; | ||
| } | ||
| nonFenceChunk.push(line); | ||
| } | ||
|
|
||
| flushNonFenceChunk(); | ||
| return output.join("\n"); | ||
| } | ||
|
|
||
| function LinkBlock({ urls }: LinkBlockProps) { | ||
| return ( | ||
| <div className="markdown-linkblock"> | ||
|
|
@@ -434,15 +551,20 @@ export function Markdown({ | |
| codeBlock, | ||
| codeBlockStyle = "default", | ||
| codeBlockCopyUseModifier = false, | ||
| enableMathRendering = false, | ||
| showFilePath = true, | ||
| workspacePath = null, | ||
| onOpenFileLink, | ||
| onOpenFileLinkMenu, | ||
| onOpenThreadLink, | ||
| }: MarkdownProps) { | ||
| const markdownValue = codeBlock ? value : normalizeListIndentation(value); | ||
| const mathNormalizedValue = !codeBlock && enableMathRendering | ||
| ? normalizeLatexMathDelimiters(markdownValue) | ||
| : markdownValue; | ||
| const normalizedValue = codeBlock | ||
| ? value | ||
| : normalizeStructuredReviewTables(normalizeListIndentation(value)); | ||
| ? mathNormalizedValue | ||
| : normalizeStructuredReviewTables(mathNormalizedValue); | ||
| const content = codeBlock | ||
| ? `\`\`\`\n${normalizedValue}\n\`\`\`` | ||
| : normalizedValue; | ||
|
|
@@ -611,7 +733,12 @@ export function Markdown({ | |
| return ( | ||
| <div className={className}> | ||
| <ReactMarkdown | ||
| remarkPlugins={[remarkGfm, remarkFileLinks]} | ||
| remarkPlugins={ | ||
| enableMathRendering | ||
| ? [remarkGfm, remarkMath, remarkFileLinks] | ||
| : [remarkGfm, remarkFileLinks] | ||
| } | ||
| rehypePlugins={enableMathRendering ? [rehypeKatex] : undefined} | ||
| urlTransform={(url) => { | ||
| const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url); | ||
| // Keep file-like hrefs intact before scheme sanitization runs, otherwise | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When math rendering is enabled, fence detection only matches lines that start with optional spaces followed by backticks/tilde. Valid Markdown fenced blocks inside containers (for example$/$ $...$ $` before parsing. These sections still render as code, but their literal text is corrupted for users copying examples from quoted/listed code blocks.
> ```...````, or list-item fences) are not recognized as code regions, so(...)and[...]inside those blocks are rewritten to$...Useful? React with 👍 / 👎.