| 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{'); |
| } |
|
|