markdown.tsx 4.4 KB

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