markdown.tsx 5.7 KB

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