markdown.tsx 4.7 KB

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