markdown.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  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. import { nanoid } from "nanoid";
  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 HTMLPreview(props: { code: string }) {
  58. const ref = useRef<HTMLDivElement>(null);
  59. const frameId = useRef<string>(nanoid());
  60. const [height, setHeight] = useState(600);
  61. /*
  62. * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
  63. * 1. using srcdoc
  64. * 2. using src with dataurl:
  65. * easy to share
  66. * length limit (Data URIs cannot be larger than 32,768 characters.)
  67. */
  68. useEffect(() => {
  69. window.addEventListener("message", (e) => {
  70. const { id, height } = e.data;
  71. if (id == frameId.current) {
  72. console.log("setHeight", height);
  73. if (height < 600) {
  74. setHeight(height + 40);
  75. }
  76. }
  77. });
  78. }, []);
  79. const script = encodeURIComponent(
  80. `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`,
  81. );
  82. return (
  83. <div
  84. className="no-dark html"
  85. style={{
  86. cursor: "pointer",
  87. overflow: "auto",
  88. }}
  89. onClick={(e) => e.stopPropagation()}
  90. >
  91. <iframe
  92. id={frameId.current}
  93. ref={ref}
  94. frameBorder={0}
  95. sandbox="allow-forms allow-modals allow-scripts"
  96. style={{ width: "100%", height }}
  97. src={`data:text/html,${encodeURIComponent(props.code)}${script}`}
  98. // srcDoc={props.code + script}
  99. ></iframe>
  100. </div>
  101. );
  102. }
  103. export function PreCode(props: { children: any }) {
  104. const ref = useRef<HTMLPreElement>(null);
  105. const refText = ref.current?.innerText;
  106. const [mermaidCode, setMermaidCode] = useState("");
  107. const [htmlCode, setHtmlCode] = useState("");
  108. const renderArtifacts = useDebouncedCallback(() => {
  109. if (!ref.current) return;
  110. const mermaidDom = ref.current.querySelector("code.language-mermaid");
  111. if (mermaidDom) {
  112. setMermaidCode((mermaidDom as HTMLElement).innerText);
  113. }
  114. const htmlDom = ref.current.querySelector("code.language-html");
  115. if (htmlDom) {
  116. setHtmlCode((htmlDom as HTMLElement).innerText);
  117. }
  118. }, 600);
  119. useEffect(() => {
  120. setTimeout(renderArtifacts, 1);
  121. // eslint-disable-next-line react-hooks/exhaustive-deps
  122. }, [refText]);
  123. return (
  124. <>
  125. <pre ref={ref}>
  126. <span
  127. className="copy-code-button"
  128. onClick={() => {
  129. if (ref.current) {
  130. const code = ref.current.innerText;
  131. copyToClipboard(code);
  132. }
  133. }}
  134. ></span>
  135. {props.children}
  136. </pre>
  137. {mermaidCode.length > 0 && (
  138. <Mermaid code={mermaidCode} key={mermaidCode} />
  139. )}
  140. {htmlCode.length > 0 && <HTMLPreview code={htmlCode} key={htmlCode} />}
  141. </>
  142. );
  143. }
  144. function escapeDollarNumber(text: string) {
  145. let escapedText = "";
  146. for (let i = 0; i < text.length; i += 1) {
  147. let char = text[i];
  148. const nextChar = text[i + 1] || " ";
  149. if (char === "$" && nextChar >= "0" && nextChar <= "9") {
  150. char = "\\$";
  151. }
  152. escapedText += char;
  153. }
  154. return escapedText;
  155. }
  156. function escapeBrackets(text: string) {
  157. const pattern =
  158. /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
  159. return text.replace(
  160. pattern,
  161. (match, codeBlock, squareBracket, roundBracket) => {
  162. if (codeBlock) {
  163. return codeBlock;
  164. } else if (squareBracket) {
  165. return `$$${squareBracket}$$`;
  166. } else if (roundBracket) {
  167. return `$${roundBracket}$`;
  168. }
  169. return match;
  170. },
  171. );
  172. }
  173. function _MarkDownContent(props: { content: string }) {
  174. const escapedContent = useMemo(() => {
  175. return escapeBrackets(escapeDollarNumber(props.content));
  176. }, [props.content]);
  177. return (
  178. <ReactMarkdown
  179. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  180. rehypePlugins={[
  181. RehypeKatex,
  182. [
  183. RehypeHighlight,
  184. {
  185. detect: false,
  186. ignoreMissing: true,
  187. },
  188. ],
  189. ]}
  190. components={{
  191. pre: PreCode,
  192. p: (pProps) => <p {...pProps} dir="auto" />,
  193. a: (aProps) => {
  194. const href = aProps.href || "";
  195. const isInternal = /^\/#/i.test(href);
  196. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  197. return <a {...aProps} target={target} />;
  198. },
  199. }}
  200. >
  201. {escapedContent}
  202. </ReactMarkdown>
  203. );
  204. }
  205. export const MarkdownContent = React.memo(_MarkDownContent);
  206. export function Markdown(
  207. props: {
  208. content: string;
  209. loading?: boolean;
  210. fontSize?: number;
  211. parentRef?: RefObject<HTMLDivElement>;
  212. defaultShow?: boolean;
  213. } & React.DOMAttributes<HTMLDivElement>,
  214. ) {
  215. const mdRef = useRef<HTMLDivElement>(null);
  216. return (
  217. <div
  218. className="markdown-body"
  219. style={{
  220. fontSize: `${props.fontSize ?? 14}px`,
  221. }}
  222. ref={mdRef}
  223. onContextMenu={props.onContextMenu}
  224. onDoubleClickCapture={props.onDoubleClickCapture}
  225. dir="auto"
  226. >
  227. {props.loading ? (
  228. <LoadingIcon />
  229. ) : (
  230. <MarkdownContent content={props.content} />
  231. )}
  232. </div>
  233. );
  234. }