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