markdown.tsx 5.4 KB

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