markdown.tsx 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  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 LoadingIcon from "../icons/three-dots.svg";
  11. export function PreCode(props: { children: any }) {
  12. const ref = useRef<HTMLPreElement>(null);
  13. return (
  14. <pre ref={ref}>
  15. <span
  16. className="copy-code-button"
  17. onClick={() => {
  18. if (ref.current) {
  19. const code = ref.current.innerText;
  20. copyToClipboard(code);
  21. }
  22. }}
  23. ></span>
  24. {props.children}
  25. </pre>
  26. );
  27. }
  28. const useLazyLoad = (ref: RefObject<Element>): boolean => {
  29. const [isIntersecting, setIntersecting] = useState<boolean>(false);
  30. useEffect(() => {
  31. const observer = new IntersectionObserver(([entry]) => {
  32. if (entry.isIntersecting) {
  33. setIntersecting(true);
  34. observer.disconnect();
  35. }
  36. });
  37. if (ref.current) {
  38. observer.observe(ref.current);
  39. }
  40. return () => {
  41. observer.disconnect();
  42. };
  43. // eslint-disable-next-line react-hooks/exhaustive-deps
  44. }, []);
  45. return isIntersecting;
  46. };
  47. export function Markdown(
  48. props: {
  49. content: string;
  50. loading?: boolean;
  51. fontSize?: number;
  52. parentRef: RefObject<HTMLDivElement>;
  53. } & React.DOMAttributes<HTMLDivElement>,
  54. ) {
  55. const mdRef = useRef<HTMLDivElement>(null);
  56. const parent = props.parentRef.current;
  57. const md = mdRef.current;
  58. const rendered = useRef(false);
  59. const [counter, setCounter] = useState(0);
  60. useEffect(() => {
  61. // to triggr rerender
  62. setCounter(counter + 1);
  63. // eslint-disable-next-line react-hooks/exhaustive-deps
  64. }, [props.loading]);
  65. const inView =
  66. rendered.current ||
  67. (() => {
  68. if (parent && md) {
  69. const parentBounds = parent.getBoundingClientRect();
  70. const mdBounds = md.getBoundingClientRect();
  71. const isInRange = (x: number) =>
  72. x <= parentBounds.bottom && x >= parentBounds.top;
  73. const inView = isInRange(mdBounds.top) || isInRange(mdBounds.bottom);
  74. if (inView) {
  75. rendered.current = true;
  76. }
  77. return inView;
  78. }
  79. })();
  80. const shouldLoading = props.loading || !inView;
  81. return (
  82. <div
  83. className="markdown-body"
  84. style={{ fontSize: `${props.fontSize ?? 14}px` }}
  85. ref={mdRef}
  86. onContextMenu={props.onContextMenu}
  87. onDoubleClickCapture={props.onDoubleClickCapture}
  88. >
  89. {shouldLoading ? (
  90. <LoadingIcon />
  91. ) : (
  92. <ReactMarkdown
  93. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  94. rehypePlugins={[
  95. RehypeKatex,
  96. [
  97. RehypeHighlight,
  98. {
  99. detect: false,
  100. ignoreMissing: true,
  101. },
  102. ],
  103. ]}
  104. components={{
  105. pre: PreCode,
  106. }}
  107. linkTarget={"_blank"}
  108. >
  109. {props.content}
  110. </ReactMarkdown>
  111. )}
  112. </div>
  113. );
  114. }