diff --git a/surfsense_web/components/assistant-ui/markdown-code-block.tsx b/surfsense_web/components/assistant-ui/markdown-code-block.tsx new file mode 100644 index 000000000..3c8d79ec9 --- /dev/null +++ b/surfsense_web/components/assistant-ui/markdown-code-block.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { CheckIcon, CopyIcon } from "lucide-react"; +import type { CSSProperties } from "react"; +import { memo, useEffect, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn, copyToClipboard } from "@/lib/utils"; + +type MarkdownCodeBlockProps = { + className?: string; + language: string; + codeText: string; + isDarkMode: boolean; +}; + +function stripThemeBackgrounds( + theme: Record +): Record { + const cleaned: Record = {}; + for (const key of Object.keys(theme)) { + const { background, backgroundColor, ...rest } = theme[key] as CSSProperties & { + background?: string; + backgroundColor?: string; + }; + cleaned[key] = rest; + } + return cleaned; +} + +const cleanMaterialDark = stripThemeBackgrounds(materialDark); +const cleanMaterialLight = stripThemeBackgrounds(materialLight); + +function MarkdownCodeBlockComponent({ + className, + language, + codeText, + isDarkMode, +}: MarkdownCodeBlockProps) { + const [hasCopied, setHasCopied] = useState(false); + + useEffect(() => { + if (!hasCopied) return; + const timer = setTimeout(() => setHasCopied(false), 2000); + return () => clearTimeout(timer); + }, [hasCopied]); + + return ( +
+
+ {language} + +
+ + + {codeText} + +
+ ); +} + +export const MarkdownCodeBlock = memo(MarkdownCodeBlockComponent); + +export function MarkdownCodeBlockSkeleton() { + return ( +
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 3d33463b2..d26364b6d 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -7,19 +7,17 @@ import { unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock, } from "@assistant-ui/react-markdown"; -import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react"; +import { ExternalLinkIcon } from "lucide-react"; +import dynamic from "next/dynamic"; import { useTheme } from "next-themes"; -import type { CSSProperties } from "react"; -import { type FC, memo, type ReactNode, useState } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { memo, type ReactNode } from "react"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image"; import "katex/dist/katex.min.css"; import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, @@ -30,22 +28,32 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib/utils"; -function stripThemeBackgrounds( - theme: Record -): Record { - const cleaned: Record = {}; - for (const key of Object.keys(theme)) { - const { background, backgroundColor, ...rest } = theme[key] as CSSProperties & { - background?: string; - backgroundColor?: string; - }; - cleaned[key] = rest; - } - return cleaned; +function MarkdownCodeBlockSkeleton() { + return ( +
+
+ + +
+
+ + + + +
+
+ ); } -const cleanMaterialDark = stripThemeBackgrounds(materialDark); -const cleanMaterialLight = stripThemeBackgrounds(materialLight); +const LazyMarkdownCodeBlock = dynamic( + () => import("./markdown-code-block").then((mod) => mod.MarkdownCodeBlock), + { + loading: () => , + } +); // Storage for URL citations replaced during preprocess to avoid GFM autolink interference. // Populated in preprocessMarkdown, consumed in parseTextWithCitations. @@ -178,39 +186,6 @@ const MarkdownTextImpl = () => { export const MarkdownText = memo(MarkdownTextImpl); -const InlineCodeHeader: FC<{ language: string; code: string }> = ({ language, code }) => { - const { isCopied, copyToClipboard } = useCopyToClipboard(); - const onCopy = () => { - if (!code || isCopied) return; - copyToClipboard(code); - }; - - return ( -
- {language} - - {!isCopied && } - {isCopied && } - -
- ); -}; - -const useCopyToClipboard = ({ copiedDuration = 3000 }: { copiedDuration?: number } = {}) => { - const [isCopied, setIsCopied] = useState(false); - - const copyToClipboard = (value: string) => { - if (!value) return; - - navigator.clipboard.writeText(value).then(() => { - setIsCopied(true); - setTimeout(() => setIsCopied(false), copiedDuration); - }); - }; - - return { isCopied, copyToClipboard }; -}; - /** * Helper to process children and replace citation patterns with components */ @@ -426,19 +401,13 @@ const defaultComponents = memoizeMarkdownComponents({ } const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text"; const codeString = String(children).replace(/\n$/, ""); - const syntaxStyle = resolvedTheme === "dark" ? cleanMaterialDark : cleanMaterialLight; return ( -
- - - {codeString} - -
+ ); }, strong: ({ className, children, ...props }) => (