File size: 10,819 Bytes
f59fbe2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
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 <span className="inline-block size-2.75 rounded-sm align-middle mr-0.5 mb-0.5" style={{ backgroundColor: color }} />;
}

export function FileLink({ path, display }: { path: string; display: string }) {
  const onClick = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      void bridge.openFile(path);
    },
    [path],
  );
  return (
    <button type="button" className="hover:text-zinc-900 dark:hover:text-white hover:underline cursor-pointer break-all text-left" onClick={onClick}>
      {display}
    </button>
  );
}

function VideoLink({ src }: { src: string }) {
  const filename = src.split("/").pop() || src;
  return (
    <button
      type="button"
      onClick={() => {
        void bridge.openFile(src);
      }}
      className="inline-flex items-center gap-1.5 px-2 py-1 my-1 rounded bg-muted hover:bg-muted/80 text-xs cursor-pointer"
    >
      <IconVideo className="size-4 text-muted-foreground" />
      <span>{filename}</span>
    </button>
  );
}

function EnrichedText({ text, fileMap }: { text: string; fileMap: Record<string, boolean> }) {
  const segments = useMemo(() => parseSegments(text, fileMap), [text, fileMap]);
  return (
    <>
      {segments.map((seg, i) => {
        if (seg.type === "color") {
          return (
            <span key={i}>
              <ColorSwatch color={seg.value} />
              {seg.value}
            </span>
          );
        }
        if (seg.type === "file") {
          return <FileLink key={i} path={seg.path} display={seg.value} />;
        }
        return <span key={i}>{seg.value}</span>;
      })}
    </>
  );
}

function enrichChildren(children: React.ReactNode, fileMap: Record<string, boolean>): React.ReactNode {
  return React.Children.map(children, (child) => {
    if (typeof child === "string") {
      return <EnrichedText text={child} fileMap={fileMap} />;
    }
    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 <ImageLoadFail path={src} />;
  return <StreamImagePreview src={data} alt={alt || src} onPreview={onPreview} />;
}

function ColorEnrichedText({ text }: { text: string }) {
  const segments = useMemo(() => parseColorSegments(text), [text]);
  return (
    <>
      {segments.map((seg, i) =>
        seg.type === "color" ? (
          <span key={i}>
            <ColorSwatch color={seg.value} />
            {seg.value}
          </span>
        ) : (
          <span key={i}>{seg.value}</span>
        ),
      )}
    </>
  );
}

const CodeBlock = memo(function CodeBlock({ code, language, enableHighlight, style }: { code: string; language?: string; enableHighlight: boolean; style?: any }) {
  return (
    <div className="relative group/code">
      <CopyButton content={code} className="absolute right-1 top-1 opacity-0 group-hover/code:opacity-100" />
      {enableHighlight && language ? (
        <SyntaxHighlighter
          style={style}
          language={language}
          PreTag="div"
          customStyle={{ padding: "0.5rem", borderRadius: "0.375rem", fontSize: "11px", margin: 0 }}
          codeTagProps={{ style: { backgroundColor: "transparent", fontFamily: "inherit", padding: 0, color: "inherit", borderRadius: 0 } }}
        >
          {code}
        </SyntaxHighlighter>
      ) : (
        <pre className="bg-muted rounded px-2 py-1 overflow-x-auto text-[11px]">
          <code className="bg-transparent!">{code}</code>
        </pre>
      )}
    </div>
  );
});

// FIX: Replaced unwrapSingleParagraph with unwrapParagraphs.
// The old version only stripped <p> when it was the sole child of <li>.
// In "loose lists" (items separated by blank lines) with nested sub-lists,
// ReactMarkdown produces [<p>, <ul>] as children — two elements — so the
// old single-child check failed and the inner <p mb-2> was kept, adding
// unwanted vertical spacing inside list items.
// This version strips <p> 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<Record<string, boolean>>({});
  const [previewSrc, setPreviewSrc] = useState<string | null>(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 }) => <p className="mb-2 last:mb-0">{enrich(children)}</p>,
      li: ({ children }) => <li>{enrich(unwrapParagraphs(children))}</li>,
      strong: ({ children }) => <strong>{enrich(children)}</strong>,
      em: ({ children }) => <em>{enrich(children)}</em>,
      td: ({ children }) => <td className="border border-border px-2 py-1">{enrich(children)}</td>,
      th: ({ children }) => <th className="border border-border bg-muted px-2 py-1 text-left font-medium">{enrich(children)}</th>,
      h1: ({ children }) => <h1 className="text-base font-bold mt-4 mb-2">{children}</h1>,
      h2: ({ children }) => <h2 className="text-sm font-bold mt-3 mb-2">{children}</h2>,
      h3: ({ children }) => <h3 className="text-sm font-semibold mt-2 mb-1">{children}</h3>,
      ul: ({ children }) => <ul className="list-disc list-outside pl-5 mb-2 space-y-1">{children}</ul>,
      ol: ({ children }) => <ol className="list-decimal list-outside pl-5 mb-2 space-y-1">{children}</ol>,
      a: ({ href, children }) => (
        <a href={href} className="text-blue-600 dark:text-blue-400 underline hover:no-underline" target="_blank" rel="noopener noreferrer">
          {children}
        </a>
      ),
      blockquote: ({ children }) => <blockquote className="border-l-2 border-primary/50 pl-3 my-2 text-muted-foreground italic">{children}</blockquote>,
      table: ({ children }) => (
        <div className="overflow-x-auto my-2">
          <table className="min-w-full text-xs border border-border">{children}</table>
        </div>
      ),
      hr: () => <hr className="my-3 border-border" />,
      img: ({ src, alt }) => {
        if (!src) return null;
        if (!enableLocalImageRender) return <span className="text-muted-foreground">{src}</span>;

        if (getMediaTypeFromSrc(src) === "video") {
          return isLocalPath(src) ? <VideoLink src={src} /> : null;
        }
        if (isLocalPath(src)) {
          return <LocalImage src={src} alt={alt} onPreview={setPreviewSrc} />;
        }
        return <StreamImagePreview src={src} alt={alt} onPreview={setPreviewSrc} />;
      },
      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 (
            <code className="bg-muted px-1 py-0.5 rounded text-[11px]" {...props}>
              {showColor ? <ColorEnrichedText text={code} /> : children}
            </code>
          );
        }
        return <CodeBlock code={code} language={match?.[1]} enableHighlight={enableEnrichment && !!match} style={codeStyle} />;
      },
    };
  }, [enableEnrichment, enableLocalImageRender, fileMap, codeStyle]);

  if (!content) return null;

  return (
    <div className={className}>
      <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components}>
        {content}
      </ReactMarkdown>
      <MediaPreviewModal src={previewSrc} onClose={() => setPreviewSrc(null)} />
    </div>
  );
});