markdown.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import ReactMarkdown from "react-markdown";
  2. import "katex/dist/katex.min.css";
  3. import RemarkMath from "remark-math";
  4. import RemarkBreaks from "remark-breaks";
  5. import RehypeKatex from "rehype-katex";
  6. import RemarkGfm from "remark-gfm";
  7. import RehypeHighlight from "rehype-highlight";
  8. import { useRef, useState, RefObject, useEffect, useMemo } from "react";
  9. import { copyToClipboard, useWindowSize } from "../utils";
  10. import mermaid from "mermaid";
  11. import LoadingIcon from "../icons/three-dots.svg";
  12. import React from "react";
  13. import { useDebouncedCallback } from "use-debounce";
  14. import { showImageModal, FullScreen } from "./ui-lib";
  15. import { ArtifactShareButton, HTMLPreview } from "./artifact";
  16. export function Mermaid(props: { code: string }) {
  17. const ref = useRef<HTMLDivElement>(null);
  18. const [hasError, setHasError] = useState(false);
  19. useEffect(() => {
  20. if (props.code && ref.current) {
  21. mermaid
  22. .run({
  23. nodes: [ref.current],
  24. suppressErrors: true,
  25. })
  26. .catch((e) => {
  27. setHasError(true);
  28. console.error("[Mermaid] ", e.message);
  29. });
  30. }
  31. // eslint-disable-next-line react-hooks/exhaustive-deps
  32. }, [props.code]);
  33. function viewSvgInNewWindow() {
  34. const svg = ref.current?.querySelector("svg");
  35. if (!svg) return;
  36. const text = new XMLSerializer().serializeToString(svg);
  37. const blob = new Blob([text], { type: "image/svg+xml" });
  38. showImageModal(URL.createObjectURL(blob));
  39. }
  40. if (hasError) {
  41. return null;
  42. }
  43. return (
  44. <div
  45. className="no-dark mermaid"
  46. style={{
  47. cursor: "pointer",
  48. overflow: "auto",
  49. }}
  50. ref={ref}
  51. onClick={() => viewSvgInNewWindow()}
  52. >
  53. {props.code}
  54. </div>
  55. );
  56. }
  57. export function PreCode(props: { children: any }) {
  58. const ref = useRef<HTMLPreElement>(null);
  59. const refText = ref.current?.innerText;
  60. const [mermaidCode, setMermaidCode] = useState("");
  61. const [htmlCode, setHtmlCode] = useState("");
  62. const { height } = useWindowSize();
  63. const renderArtifacts = useDebouncedCallback(() => {
  64. if (!ref.current) return;
  65. const mermaidDom = ref.current.querySelector("code.language-mermaid");
  66. if (mermaidDom) {
  67. setMermaidCode((mermaidDom as HTMLElement).innerText);
  68. }
  69. const htmlDom = ref.current.querySelector("code.language-html");
  70. if (htmlDom) {
  71. setHtmlCode((htmlDom as HTMLElement).innerText);
  72. } else if (refText?.startsWith("<!DOCTYPE")) {
  73. setHtmlCode(refText);
  74. }
  75. }, 600);
  76. useEffect(() => {
  77. setTimeout(renderArtifacts, 1);
  78. // eslint-disable-next-line react-hooks/exhaustive-deps
  79. }, [refText]);
  80. return (
  81. <>
  82. <pre ref={ref}>
  83. <span
  84. className="copy-code-button"
  85. onClick={() => {
  86. if (ref.current) {
  87. const code = ref.current.innerText;
  88. copyToClipboard(code);
  89. }
  90. }}
  91. ></span>
  92. {props.children}
  93. </pre>
  94. {mermaidCode.length > 0 && (
  95. <Mermaid code={mermaidCode} key={mermaidCode} />
  96. )}
  97. {htmlCode.length > 0 && (
  98. <FullScreen className="no-dark html" right={70}>
  99. <ArtifactShareButton
  100. style={{ position: "absolute", right: 20, top: 10 }}
  101. getCode={() => htmlCode}
  102. />
  103. <HTMLPreview
  104. code={htmlCode}
  105. autoHeight={!document.fullscreenElement}
  106. height={!document.fullscreenElement ? 600 : height}
  107. />
  108. </FullScreen>
  109. )}
  110. </>
  111. );
  112. }
  113. function escapeDollarNumber(text: string) {
  114. let escapedText = "";
  115. for (let i = 0; i < text.length; i += 1) {
  116. let char = text[i];
  117. const nextChar = text[i + 1] || " ";
  118. if (char === "$" && nextChar >= "0" && nextChar <= "9") {
  119. char = "\\$";
  120. }
  121. escapedText += char;
  122. }
  123. return escapedText;
  124. }
  125. function escapeBrackets(text: string) {
  126. const pattern =
  127. /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
  128. return text.replace(
  129. pattern,
  130. (match, codeBlock, squareBracket, roundBracket) => {
  131. if (codeBlock) {
  132. return codeBlock;
  133. } else if (squareBracket) {
  134. return `$$${squareBracket}$$`;
  135. } else if (roundBracket) {
  136. return `$${roundBracket}$`;
  137. }
  138. return match;
  139. },
  140. );
  141. }
  142. function _MarkDownContent(props: { content: string }) {
  143. const escapedContent = useMemo(() => {
  144. return escapeBrackets(escapeDollarNumber(props.content));
  145. }, [props.content]);
  146. return (
  147. <ReactMarkdown
  148. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  149. rehypePlugins={[
  150. RehypeKatex,
  151. [
  152. RehypeHighlight,
  153. {
  154. detect: false,
  155. ignoreMissing: true,
  156. },
  157. ],
  158. ]}
  159. components={{
  160. pre: PreCode,
  161. p: (pProps) => <p {...pProps} dir="auto" />,
  162. a: (aProps) => {
  163. const href = aProps.href || "";
  164. const isInternal = /^\/#/i.test(href);
  165. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  166. return <a {...aProps} target={target} />;
  167. },
  168. }}
  169. >
  170. {escapedContent}
  171. </ReactMarkdown>
  172. );
  173. }
  174. export const MarkdownContent = React.memo(_MarkDownContent);
  175. export function Markdown(
  176. props: {
  177. content: string;
  178. loading?: boolean;
  179. fontSize?: number;
  180. parentRef?: RefObject<HTMLDivElement>;
  181. defaultShow?: boolean;
  182. } & React.DOMAttributes<HTMLDivElement>,
  183. ) {
  184. const mdRef = useRef<HTMLDivElement>(null);
  185. return (
  186. <div
  187. className="markdown-body"
  188. style={{
  189. fontSize: `${props.fontSize ?? 14}px`,
  190. }}
  191. ref={mdRef}
  192. onContextMenu={props.onContextMenu}
  193. onDoubleClickCapture={props.onDoubleClickCapture}
  194. dir="auto"
  195. >
  196. {props.loading ? (
  197. <LoadingIcon />
  198. ) : (
  199. <MarkdownContent content={props.content} />
  200. )}
  201. </div>
  202. );
  203. }