markdown.tsx 6.0 KB

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