mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-12 21:13:43 +08:00
117 lines
3.0 KiB
TypeScript
117 lines
3.0 KiB
TypeScript
import ReactMarkdown from "react-markdown";
|
||
import "katex/dist/katex.min.css";
|
||
import RemarkMath from "remark-math";
|
||
import RemarkBreaks from "remark-breaks";
|
||
import RehypeKatex from "rehype-katex";
|
||
import RemarkGfm from "remark-gfm";
|
||
import RehypeHighlight from "rehype-highlight";
|
||
import { useRef, useState, RefObject, useEffect } from "react";
|
||
import { copyToClipboard } from "../utils";
|
||
|
||
import LoadingIcon from "../icons/three-dots.svg";
|
||
|
||
export function PreCode(props: { children: any }) {
|
||
const ref = useRef<HTMLPreElement>(null);
|
||
|
||
return (
|
||
<pre ref={ref}>
|
||
<span
|
||
className="copy-code-button"
|
||
onClick={() => {
|
||
if (ref.current) {
|
||
const code = ref.current.innerText;
|
||
copyToClipboard(code);
|
||
}
|
||
}}
|
||
></span>
|
||
{props.children}
|
||
</pre>
|
||
);
|
||
}
|
||
|
||
export function Markdown(
|
||
props: {
|
||
content: string;
|
||
loading?: boolean;
|
||
fontSize?: number;
|
||
parentRef: RefObject<HTMLDivElement>;
|
||
} & React.DOMAttributes<HTMLDivElement>,
|
||
) {
|
||
const mdRef = useRef<HTMLDivElement>(null);
|
||
|
||
const parent = props.parentRef.current;
|
||
const md = mdRef.current;
|
||
const rendered = useRef(true); // disable lazy loading for bad ux
|
||
const [counter, setCounter] = useState(0);
|
||
|
||
useEffect(() => {
|
||
// to triggr rerender
|
||
setCounter(counter + 1);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [props.loading]);
|
||
|
||
const inView =
|
||
rendered.current ||
|
||
(() => {
|
||
if (parent && md) {
|
||
const parentBounds = parent.getBoundingClientRect();
|
||
const mdBounds = md.getBoundingClientRect();
|
||
const isInRange = (x: number) =>
|
||
x <= parentBounds.bottom && x >= parentBounds.top;
|
||
const inView = isInRange(mdBounds.top) || isInRange(mdBounds.bottom);
|
||
|
||
if (inView) {
|
||
rendered.current = true;
|
||
}
|
||
|
||
return inView;
|
||
}
|
||
})();
|
||
|
||
const shouldLoading = props.loading || !inView;
|
||
|
||
return (
|
||
<div
|
||
className="markdown-body"
|
||
style={{ fontSize: `${props.fontSize ?? 14}px` }}
|
||
ref={mdRef}
|
||
onContextMenu={props.onContextMenu}
|
||
onDoubleClickCapture={props.onDoubleClickCapture}
|
||
>
|
||
{shouldLoading ? (
|
||
<LoadingIcon />
|
||
) : (
|
||
<ReactMarkdown
|
||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||
rehypePlugins={[
|
||
RehypeKatex,
|
||
[
|
||
RehypeHighlight,
|
||
{
|
||
detect: false,
|
||
ignoreMissing: true,
|
||
},
|
||
],
|
||
]}
|
||
components={{
|
||
pre: PreCode,
|
||
}}
|
||
renderers={{
|
||
link: (props) => {
|
||
// 判断链接是否为内部地址
|
||
const isInternal = /^\/(?!\/)/.test(props.href);
|
||
|
||
// 如果是内部地址,使用_self,否则使用_blank
|
||
const target = isInternal ? "_self" : "_blank";
|
||
|
||
return <a {...props} target={target} />;
|
||
},
|
||
}}
|
||
>
|
||
{props.content}
|
||
</ReactMarkdown>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|