|
import React, { useMemo, useState } from 'react'; |
|
import Markdown, { ExtraProps } from 'react-markdown'; |
|
import remarkGfm from 'remark-gfm'; |
|
import rehypeHightlight from 'rehype-highlight'; |
|
import rehypeKatex from 'rehype-katex'; |
|
import remarkMath from 'remark-math'; |
|
import remarkBreaks from 'remark-breaks'; |
|
import 'katex/dist/katex.min.css'; |
|
import { classNames, copyStr } from '../utils/misc'; |
|
import { ElementContent, Root } from 'hast'; |
|
import { visit } from 'unist-util-visit'; |
|
import { useAppContext } from '../utils/app.context'; |
|
import { CanvasType } from '../utils/types'; |
|
|
|
export default function MarkdownDisplay({ |
|
content, |
|
isGenerating, |
|
}: { |
|
content: string; |
|
isGenerating?: boolean; |
|
}) { |
|
const preprocessedContent = useMemo( |
|
() => preprocessLaTeX(content), |
|
[content] |
|
); |
|
return ( |
|
<Markdown |
|
remarkPlugins={[remarkGfm, remarkMath, remarkBreaks]} |
|
rehypePlugins={[rehypeHightlight, rehypeKatex, rehypeCustomCopyButton]} |
|
components={{ |
|
button: (props) => ( |
|
<CodeBlockButtons |
|
{...props} |
|
isGenerating={isGenerating} |
|
origContent={preprocessedContent} |
|
/> |
|
), |
|
// note: do not use "pre", "p" or other basic html elements here, it will cause the node to re-render when the message is being generated (this should be a bug with react-markdown, not sure how to fix it) |
|
}} |
|
> |
|
{preprocessedContent} |
|
</Markdown> |
|
); |
|
} |
|
|
|
const CodeBlockButtons: React.ElementType< |
|
React.ClassAttributes<HTMLButtonElement> & |
|
React.HTMLAttributes<HTMLButtonElement> & |
|
ExtraProps & { origContent: string; isGenerating?: boolean } |
|
> = ({ node, origContent, isGenerating }) => { |
|
const { config } = useAppContext(); |
|
const startOffset = node?.position?.start.offset ?? 0; |
|
const endOffset = node?.position?.end.offset ?? 0; |
|
|
|
const copiedContent = useMemo( |
|
() => |
|
origContent |
|
.substring(startOffset, endOffset) |
|
.replace(/^```[^\n]+\n/g, '') |
|
.replace(/```$/g, ''), |
|
[origContent, startOffset, endOffset] |
|
); |
|
|
|
const codeLanguage = useMemo( |
|
() => |
|
origContent |
|
.substring(startOffset, startOffset + 10) |
|
.match(/^```([^\n]+)\n/)?.[1] ?? '', |
|
[origContent, startOffset] |
|
); |
|
|
|
const canRunCode = |
|
!isGenerating && |
|
config.pyIntepreterEnabled && |
|
codeLanguage.startsWith('py'); |
|
|
|
return ( |
|
<div |
|
className={classNames({ |
|
'text-right sticky top-[7em] mb-2 mr-2 h-0': true, |
|
'display-none': !node?.position, |
|
})} |
|
> |
|
<CopyButton className="badge btn-mini" content={copiedContent} /> |
|
{canRunCode && ( |
|
<RunPyCodeButton |
|
className="badge btn-mini ml-2" |
|
content={copiedContent} |
|
/> |
|
)} |
|
</div> |
|
); |
|
}; |
|
|
|
export const CopyButton = ({ |
|
content, |
|
className, |
|
}: { |
|
content: string; |
|
className?: string; |
|
}) => { |
|
const [copied, setCopied] = useState(false); |
|
return ( |
|
<button |
|
className={className} |
|
onClick={() => { |
|
copyStr(content); |
|
setCopied(true); |
|
}} |
|
onMouseLeave={() => setCopied(false)} |
|
> |
|
{copied ? 'Copied!' : '📋 Copy'} |
|
</button> |
|
); |
|
}; |
|
|
|
export const RunPyCodeButton = ({ |
|
content, |
|
className, |
|
}: { |
|
content: string; |
|
className?: string; |
|
}) => { |
|
const { setCanvasData } = useAppContext(); |
|
return ( |
|
<> |
|
<button |
|
className={className} |
|
onClick={() => |
|
setCanvasData({ |
|
type: CanvasType.PY_INTERPRETER, |
|
content, |
|
}) |
|
} |
|
> |
|
▶️ Run |
|
</button> |
|
</> |
|
); |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
function rehypeCustomCopyButton() { |
|
return function (tree: Root) { |
|
visit(tree, 'element', function (node) { |
|
if (node.tagName === 'pre' && !node.properties.visited) { |
|
const preNode = { ...node }; |
|
|
|
preNode.properties.visited = 'true'; |
|
node.tagName = 'div'; |
|
node.properties = {}; |
|
|
|
const btnNode: ElementContent = { |
|
type: 'element', |
|
tagName: 'button', |
|
properties: {}, |
|
children: [], |
|
position: node.position, |
|
}; |
|
node.children = [btnNode, preNode]; |
|
} |
|
}); |
|
}; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const containsLatexRegex = |
|
/\\\(.*?\\\)|\\\[.*?\\\]|\$.*?\$|\\begin\{equation\}.*?\\end\{equation\}/; |
|
|
|
|
|
const inlineLatex = new RegExp(/\\\((.+?)\\\)/, 'g'); |
|
const blockLatex = new RegExp(/\\\[(.*?[^\\])\\\]/, 'gs'); |
|
|
|
|
|
const restoreCodeBlocks = (content: string, codeBlocks: string[]) => { |
|
return content.replace( |
|
/<<CODE_BLOCK_(\d+)>>/g, |
|
(_, index) => codeBlocks[index] |
|
); |
|
}; |
|
|
|
|
|
const codeBlockRegex = /(```[\s\S]*?```|`.*?`)/g; |
|
|
|
export const processLaTeX = (_content: string) => { |
|
let content = _content; |
|
|
|
const codeBlocks: string[] = []; |
|
let index = 0; |
|
content = content.replace(codeBlockRegex, (match) => { |
|
codeBlocks[index] = match; |
|
return `<<CODE_BLOCK_${index++}>>`; |
|
}); |
|
|
|
|
|
let processedContent = content.replace(/(\$)(?=\s?\d)/g, '\\$'); |
|
|
|
|
|
if (!containsLatexRegex.test(processedContent)) { |
|
return restoreCodeBlocks(processedContent, codeBlocks); |
|
} |
|
|
|
|
|
processedContent = processedContent |
|
.replace(inlineLatex, (_: string, equation: string) => `$${equation}$`) |
|
.replace(blockLatex, (_: string, equation: string) => `$$${equation}$$`); |
|
|
|
|
|
return restoreCodeBlocks(processedContent, codeBlocks); |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function preprocessLaTeX(content: string): string { |
|
|
|
const codeBlocks: string[] = []; |
|
content = content.replace(/(```[\s\S]*?```|`[^`\n]+`)/g, (_, code) => { |
|
codeBlocks.push(code); |
|
return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`; |
|
}); |
|
|
|
|
|
const latexExpressions: string[] = []; |
|
|
|
|
|
content = content.replace( |
|
/(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\(.*?\\\))/g, |
|
(match) => { |
|
latexExpressions.push(match); |
|
return `<<LATEX_${latexExpressions.length - 1}>>`; |
|
} |
|
); |
|
|
|
|
|
|
|
content = content.replace(/\$([^$]+)\$/g, (match, inner) => { |
|
if (/^\s*\d+(?:\.\d+)?\s*$/.test(inner)) { |
|
|
|
|
|
return match; |
|
} else { |
|
|
|
latexExpressions.push(match); |
|
return `<<LATEX_${latexExpressions.length - 1}>>`; |
|
} |
|
}); |
|
|
|
|
|
|
|
content = content.replace(/\$(?=\d)/g, '\\$'); |
|
|
|
|
|
content = content.replace( |
|
/<<LATEX_(\d+)>>/g, |
|
(_, index) => latexExpressions[parseInt(index)] |
|
); |
|
|
|
|
|
content = content.replace( |
|
/<<CODE_BLOCK_(\d+)>>/g, |
|
(_, index) => codeBlocks[parseInt(index)] |
|
); |
|
|
|
|
|
content = escapeBrackets(content); |
|
content = escapeMhchem(content); |
|
|
|
return content; |
|
} |
|
|
|
export function escapeBrackets(text: string): string { |
|
const pattern = |
|
/(```[\S\s]*?```|`.*?`)|\\\[([\S\s]*?[^\\])\\]|\\\((.*?)\\\)/g; |
|
return text.replace( |
|
pattern, |
|
( |
|
match: string, |
|
codeBlock: string | undefined, |
|
squareBracket: string | undefined, |
|
roundBracket: string | undefined |
|
): string => { |
|
if (codeBlock != null) { |
|
return codeBlock; |
|
} else if (squareBracket != null) { |
|
return `$$${squareBracket}$$`; |
|
} else if (roundBracket != null) { |
|
return `$${roundBracket}$`; |
|
} |
|
return match; |
|
} |
|
); |
|
} |
|
|
|
export function escapeMhchem(text: string) { |
|
return text.replaceAll('$\\ce{', '$\\\\ce{').replaceAll('$\\pu{', '$\\\\pu{'); |
|
} |
|
|