nanobot/webui/src/components/MarkdownText.tsx

116 lines
2.8 KiB
TypeScript

import {
Component,
Suspense,
lazy,
memo,
useEffect,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
interface MarkdownTextProps {
children: string;
className?: string;
streaming?: boolean;
preserveStreamingLayout?: boolean;
onOpenFilePreview?: (path: string) => void;
}
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
source,
className,
highlightCode,
streaming,
onOpenFilePreview,
}: {
source: string;
className?: string;
highlightCode: boolean;
streaming: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
return (
<LazyMarkdownRenderer
className={className}
highlightCode={highlightCode}
streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
>
{source}
</LazyMarkdownRenderer>
);
});
class MarkdownRendererBoundary extends Component<
{ children: ReactNode; fallback: ReactNode; resetKey: string },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidUpdate(previous: Readonly<{ resetKey: string }>) {
if (this.state.failed && previous.resetKey !== this.props.resetKey) {
this.setState({ failed: false });
}
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
export function preloadMarkdownText(): Promise<void> {
return loadMarkdownRenderer().then(() => undefined);
}
/** Lazy boundary for the heavier GFM, math, and code renderer. */
export function MarkdownText({
children,
className,
streaming = false,
preserveStreamingLayout = false,
onOpenFilePreview,
}: MarkdownTextProps) {
const renderedSource = children;
const renderPhase = streaming ? "streaming" : "complete";
const highlightCode = !streaming;
const renderWithStreamingLayout = streaming || preserveStreamingLayout;
useEffect(() => {
if (streaming) void preloadMarkdownText();
}, [streaming]);
const plainFallback = (
<div
className={cn(
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
streaming && "streaming-text-fallback",
className,
)}
>
{renderedSource}
</div>
);
return (
<MarkdownRendererBoundary resetKey={renderPhase} fallback={plainFallback}>
<Suspense fallback={plainFallback}>
<MemoizedMarkdownRenderer
source={renderedSource}
className={className}
highlightCode={highlightCode}
streaming={renderWithStreamingLayout}
onOpenFilePreview={onOpenFilePreview}
/>
</Suspense>
</MarkdownRendererBoundary>
);
}