markdown.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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 } 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, useThrottledCallback } from "use-debounce";
  14. export function Mermaid(props: { code: string }) {
  15. const ref = useRef<HTMLDivElement>(null);
  16. const [hasError, setHasError] = useState(false);
  17. useEffect(() => {
  18. if (props.code && ref.current) {
  19. mermaid
  20. .run({
  21. nodes: [ref.current],
  22. suppressErrors: true,
  23. })
  24. .catch((e) => {
  25. setHasError(true);
  26. console.error("[Mermaid] ", e.message);
  27. });
  28. }
  29. // eslint-disable-next-line react-hooks/exhaustive-deps
  30. }, [props.code]);
  31. function viewSvgInNewWindow() {
  32. const svg = ref.current?.querySelector("svg");
  33. if (!svg) return;
  34. const text = new XMLSerializer().serializeToString(svg);
  35. const blob = new Blob([text], { type: "image/svg+xml" });
  36. const url = URL.createObjectURL(blob);
  37. const win = window.open(url);
  38. if (win) {
  39. win.onload = () => URL.revokeObjectURL(url);
  40. }
  41. }
  42. if (hasError) {
  43. return null;
  44. }
  45. return (
  46. <div
  47. className="no-dark mermaid"
  48. style={{
  49. cursor: "pointer",
  50. overflow: "auto",
  51. }}
  52. ref={ref}
  53. onClick={() => viewSvgInNewWindow()}
  54. >
  55. {props.code}
  56. </div>
  57. );
  58. }
  59. export function PreCode(props: { children: any }) {
  60. const ref = useRef<HTMLPreElement>(null);
  61. const refText = ref.current?.innerText;
  62. const [mermaidCode, setMermaidCode] = useState("");
  63. const renderMermaid = 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. }, 600);
  70. useEffect(() => {
  71. setTimeout(renderMermaid, 1);
  72. // eslint-disable-next-line react-hooks/exhaustive-deps
  73. }, [refText]);
  74. return (
  75. <>
  76. {mermaidCode.length > 0 && (
  77. <Mermaid code={mermaidCode} key={mermaidCode} />
  78. )}
  79. <pre ref={ref}>
  80. <span
  81. className="copy-code-button"
  82. onClick={() => {
  83. if (ref.current) {
  84. const code = ref.current.innerText;
  85. copyToClipboard(code);
  86. }
  87. }}
  88. ></span>
  89. {props.children}
  90. </pre>
  91. </>
  92. );
  93. }
  94. function _MarkDownContent(props: { content: string }) {
  95. return (
  96. <ReactMarkdown
  97. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  98. rehypePlugins={[
  99. RehypeKatex,
  100. [
  101. RehypeHighlight,
  102. {
  103. detect: false,
  104. ignoreMissing: true,
  105. },
  106. ],
  107. ]}
  108. components={{
  109. pre: PreCode,
  110. a: (aProps) => {
  111. const href = aProps.href || "";
  112. const isInternal = /^\/#/i.test(href);
  113. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  114. return <a {...aProps} target={target} />;
  115. },
  116. }}
  117. >
  118. {props.content}
  119. </ReactMarkdown>
  120. );
  121. }
  122. export const MarkdownContent = React.memo(_MarkDownContent);
  123. export function Markdown(
  124. props: {
  125. content: string;
  126. loading?: boolean;
  127. fontSize?: number;
  128. parentRef?: RefObject<HTMLDivElement>;
  129. defaultShow?: boolean;
  130. } & React.DOMAttributes<HTMLDivElement>,
  131. ) {
  132. const mdRef = useRef<HTMLDivElement>(null);
  133. const renderedHeight = useRef(0);
  134. const renderedWidth = useRef(0);
  135. const inView = useRef(!!props.defaultShow);
  136. const [_, triggerRender] = useState(0);
  137. const checkInView = useThrottledCallback(
  138. () => {
  139. const parent = props.parentRef?.current;
  140. const md = mdRef.current;
  141. if (parent && md && !props.defaultShow) {
  142. const parentBounds = parent.getBoundingClientRect();
  143. const twoScreenHeight = Math.max(500, parentBounds.height * 2);
  144. const mdBounds = md.getBoundingClientRect();
  145. const parentTop = parentBounds.top - twoScreenHeight;
  146. const parentBottom = parentBounds.bottom + twoScreenHeight;
  147. const isOverlap =
  148. Math.max(parentTop, mdBounds.top) <=
  149. Math.min(parentBottom, mdBounds.bottom);
  150. inView.current = isOverlap;
  151. triggerRender(Date.now());
  152. }
  153. if (inView.current && md) {
  154. const rect = md.getBoundingClientRect();
  155. renderedHeight.current = Math.max(renderedHeight.current, rect.height);
  156. renderedWidth.current = Math.max(renderedWidth.current, rect.width);
  157. }
  158. // eslint-disable-next-line react-hooks/exhaustive-deps
  159. },
  160. 300,
  161. {
  162. leading: true,
  163. trailing: true,
  164. },
  165. );
  166. useEffect(() => {
  167. props.parentRef?.current?.addEventListener("scroll", checkInView);
  168. checkInView();
  169. return () =>
  170. props.parentRef?.current?.removeEventListener("scroll", checkInView);
  171. // eslint-disable-next-line react-hooks/exhaustive-deps
  172. }, []);
  173. const getSize = (x: number) => (!inView.current && x > 0 ? x : "auto");
  174. return (
  175. <div
  176. className="markdown-body"
  177. style={{
  178. fontSize: `${props.fontSize ?? 14}px`,
  179. height: getSize(renderedHeight.current),
  180. width: getSize(renderedWidth.current),
  181. direction: /[\u0600-\u06FF]/.test(props.content) ? "rtl" : "ltr",
  182. }}
  183. ref={mdRef}
  184. onContextMenu={props.onContextMenu}
  185. onDoubleClickCapture={props.onDoubleClickCapture}
  186. >
  187. {inView.current &&
  188. (props.loading ? (
  189. <LoadingIcon />
  190. ) : (
  191. <MarkdownContent content={props.content} />
  192. ))}
  193. </div>
  194. );
  195. }