markdown.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  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. showImageModal(URL.createObjectURL(blob));
  38. }
  39. if (hasError) {
  40. return null;
  41. }
  42. return (
  43. <div
  44. className="no-dark mermaid"
  45. style={{
  46. cursor: "pointer",
  47. overflow: "auto",
  48. }}
  49. ref={ref}
  50. onClick={() => viewSvgInNewWindow()}
  51. >
  52. {props.code}
  53. </div>
  54. );
  55. }
  56. export function PreCode(props: { children: any }) {
  57. const ref = useRef<HTMLPreElement>(null);
  58. const refText = ref.current?.innerText;
  59. const [mermaidCode, setMermaidCode] = useState("");
  60. const renderMermaid = useDebouncedCallback(() => {
  61. if (!ref.current) return;
  62. const mermaidDom = ref.current.querySelector("code.language-mermaid");
  63. if (mermaidDom) {
  64. setMermaidCode((mermaidDom as HTMLElement).innerText);
  65. }
  66. }, 600);
  67. useEffect(() => {
  68. setTimeout(renderMermaid, 1);
  69. // eslint-disable-next-line react-hooks/exhaustive-deps
  70. }, [refText]);
  71. return (
  72. <>
  73. {mermaidCode.length > 0 && (
  74. <Mermaid code={mermaidCode} key={mermaidCode} />
  75. )}
  76. <pre ref={ref}>
  77. <span
  78. className="copy-code-button"
  79. onClick={() => {
  80. if (ref.current) {
  81. const code = ref.current.innerText;
  82. copyToClipboard(code);
  83. }
  84. }}
  85. ></span>
  86. {props.children}
  87. </pre>
  88. </>
  89. );
  90. }
  91. function _MarkDownContent(props: { content: string }) {
  92. return (
  93. <ReactMarkdown
  94. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  95. rehypePlugins={[
  96. RehypeKatex,
  97. [
  98. RehypeHighlight,
  99. {
  100. detect: false,
  101. ignoreMissing: true,
  102. },
  103. ],
  104. ]}
  105. components={{
  106. pre: PreCode,
  107. p: (pProps) => <p {...pProps} dir="auto" />,
  108. a: (aProps) => {
  109. const href = aProps.href || "";
  110. const isInternal = /^\/#/i.test(href);
  111. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  112. return <a {...aProps} target={target} />;
  113. },
  114. }}
  115. >
  116. {props.content}
  117. </ReactMarkdown>
  118. );
  119. }
  120. export const MarkdownContent = React.memo(_MarkDownContent);
  121. export function Markdown(
  122. props: {
  123. content: string;
  124. loading?: boolean;
  125. fontSize?: number;
  126. parentRef?: RefObject<HTMLDivElement>;
  127. defaultShow?: boolean;
  128. } & React.DOMAttributes<HTMLDivElement>,
  129. ) {
  130. const mdRef = useRef<HTMLDivElement>(null);
  131. return (
  132. <div
  133. className="markdown-body"
  134. style={{
  135. fontSize: `${props.fontSize ?? 14}px`,
  136. }}
  137. ref={mdRef}
  138. onContextMenu={props.onContextMenu}
  139. onDoubleClickCapture={props.onDoubleClickCapture}
  140. >
  141. {props.loading ? (
  142. <LoadingIcon />
  143. ) : (
  144. <MarkdownContent content={props.content} />
  145. )}
  146. </div>
  147. );
  148. }