import React, { memo, useMemo, useState, useEffect, useCallback } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; import { useQuery } from "@tanstack/react-query"; import { IconVideo } from "@tabler/icons-react"; import type { Components } from "react-markdown"; import { parseSegments, parseColorSegments, extractPaths, checkFilesExist, hasColors, isLocalPath } from "@/lib/text-enrichment"; import { CopyButton } from "@/components/CopyButton"; import { MediaPreviewModal, StreamImagePreview, ImageLoadFail } from "@/components/MediaPreviewModal"; import { getMediaTypeFromSrc } from "@/lib/media-utils"; import { bridge } from "@/services"; interface MarkdownProps { content: string; className?: string; enableEnrichment?: boolean; enableLocalImageRender?: boolean; } function useIsDark(): boolean { const [isDark, setIsDark] = useState(() => typeof document !== "undefined" && document.documentElement.classList.contains("dark")); useEffect(() => { if (typeof document === "undefined") return; const el = document.documentElement; const obs = new MutationObserver(() => setIsDark(el.classList.contains("dark"))); obs.observe(el, { attributes: true, attributeFilter: ["class"] }); return () => obs.disconnect(); }, []); return isDark; } function ColorSwatch({ color }: { color: string }) { return ; } export function FileLink({ path, display }: { path: string; display: string }) { const onClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); void bridge.openFile(path); }, [path], ); return ( ); } function VideoLink({ src }: { src: string }) { const filename = src.split("/").pop() || src; return ( ); } function EnrichedText({ text, fileMap }: { text: string; fileMap: Record }) { const segments = useMemo(() => parseSegments(text, fileMap), [text, fileMap]); return ( <> {segments.map((seg, i) => { if (seg.type === "color") { return ( {seg.value} ); } if (seg.type === "file") { return ; } return {seg.value}; })} ); } function enrichChildren(children: React.ReactNode, fileMap: Record): React.ReactNode { return React.Children.map(children, (child) => { if (typeof child === "string") { return ; } if (!React.isValidElement(child)) { return child; } // 跳过链接和代码块(但不跳过行内 code,因为会在 code 组件中单独处理) if (typeof child.type === "string" && ["a", "pre"].includes(child.type)) { return child; } const props = child.props as { children?: React.ReactNode }; if (props.children === undefined) { return child; } return React.cloneElement(child, undefined, enrichChildren(props.children, fileMap)); }); } function LocalImage({ src, alt, onPreview }: { src: string; alt?: string; onPreview: (uri: string) => void }) { const { data } = useQuery({ queryKey: ["localImage", src], queryFn: () => bridge.getImageDataUri(src), staleTime: 10000, }); if (!data) return ; return ; } function ColorEnrichedText({ text }: { text: string }) { const segments = useMemo(() => parseColorSegments(text), [text]); return ( <> {segments.map((seg, i) => seg.type === "color" ? ( {seg.value} ) : ( {seg.value} ), )} ); } const CodeBlock = memo(function CodeBlock({ code, language, enableHighlight, style }: { code: string; language?: string; enableHighlight: boolean; style?: any }) { return (
{enableHighlight && language ? ( {code} ) : (
          {code}
        
)}
); }); // FIX: Replaced unwrapSingleParagraph with unwrapParagraphs. // The old version only stripped

when it was the sole child of

  • . // In "loose lists" (items separated by blank lines) with nested sub-lists, // ReactMarkdown produces [

    ,

      ] as children — two elements — so the // old single-child check failed and the inner

      was kept, adding // unwanted vertical spacing inside list items. // This version strips

      wrappers from ALL children, regardless of count. function unwrapParagraphs(children: React.ReactNode): React.ReactNode { return React.Children.map(children, (child) => { if (React.isValidElement(child) && child.type === "p") { return (child.props as { children?: React.ReactNode }).children; } return child; }); } export const Markdown = memo(function Markdown({ content, className, enableEnrichment = true, enableLocalImageRender = true }: MarkdownProps) { const isDark = useIsDark(); const [fileMap, setFileMap] = useState>({}); const [previewSrc, setPreviewSrc] = useState(null); useEffect(() => { // When enableEnrichment is false, skip enrichment process if (!enableEnrichment || !content) { setFileMap({}); return; } const paths = extractPaths(content); if (!paths.length) { setFileMap({}); return; } let cancelled = false; void checkFilesExist(paths).then((map) => { if (!cancelled) { setFileMap(map); } }); return () => { cancelled = true; }; }, [content, enableEnrichment]); const codeStyle = isDark ? (oneDark as any) : (oneLight as any); const components: Components = useMemo(() => { const enrich = (children: React.ReactNode) => (enableEnrichment ? enrichChildren(children, fileMap) : children); return { p: ({ children }) =>

      {enrich(children)}

      , li: ({ children }) =>
    • {enrich(unwrapParagraphs(children))}
    • , strong: ({ children }) => {enrich(children)}, em: ({ children }) => {enrich(children)}, td: ({ children }) => {enrich(children)}, th: ({ children }) => {enrich(children)}, h1: ({ children }) =>

      {children}

      , h2: ({ children }) =>

      {children}

      , h3: ({ children }) =>

      {children}

      , ul: ({ children }) =>
        {children}
      , ol: ({ children }) =>
        {children}
      , a: ({ href, children }) => ( {children} ), blockquote: ({ children }) =>
      {children}
      , table: ({ children }) => (
      {children}
      ), hr: () =>
      , img: ({ src, alt }) => { if (!src) return null; if (!enableLocalImageRender) return {src}; if (getMediaTypeFromSrc(src) === "video") { return isLocalPath(src) ? : null; } if (isLocalPath(src)) { return ; } return ; }, code: ({ className: cn, children, ...props }: any) => { const match = /language-(\w+)/.exec(cn || ""); const code = String(children ?? "").replace(/\n$/, ""); const isInline = !code.includes("\n") && !match; if (isInline) { const showColor = enableEnrichment && hasColors(code); return ( {showColor ? : children} ); } return ; }, }; }, [enableEnrichment, enableLocalImageRender, fileMap, codeStyle]); if (!content) return null; return (
      {content} setPreviewSrc(null)} />
      ); });