import { memo, useState, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; import { FileChip, FileFigure } from "./FileFigure"; export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i; /** URL for a file in the project (absolute or relative to baseDir). */ export function projectFileUrl(src: string, baseDir?: string): string | undefined { if (/^(https?:|data:|blob:)/i.test(src)) return src; if (!baseDir && !src.startsWith("/")) return undefined; const parts = (src.startsWith("/") ? src : `${baseDir}/${src}`).split("/"); const out: string[] = []; for (const p of parts) { if (p === "..") out.pop(); else if (p && p !== ".") out.push(p); } return `/api/files/raw?path=${encodeURIComponent("/" + out.join("/"))}`; } function textOf(node: ReactNode): string { if (typeof node === "string") return node; if (Array.isArray(node)) return node.map(textOf).join(""); if (node && typeof node === "object" && "props" in node) return textOf((node as any).props.children); return ""; } /** SVG code shown as an image (img context: scripts never run), code on demand. */ export function SvgBlock({ code, children }: { code: string; children?: ReactNode }) { const [showCode, setShowCode] = useState(false); const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`; return (
SVG {showCode && children}
); } export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) { return (
, img: ({ src, alt }) => { const url = typeof src === "string" ? projectFileUrl(src, baseDir) : undefined; if (!url) return {alt}; // Non-image files (3D models, documents) become links. if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) { return ; } const name = typeof src === "string" && !src.startsWith("data:") ? decodeURIComponent(src.split("?")[0]) : alt || undefined; return ( {alt ); }, pre: ({ children }) => { const code = textOf(children); const child: any = Array.isArray(children) ? children[0] : children; const cls = String(child?.props?.className ?? ""); if (/language-svg/.test(cls) || (/language-(xml|html)/.test(cls) && code.trim().startsWith("
{children}
); } return
{children}
; }, }} > {text}
); }); /** Image files mentioned in a text (paths in backticks or plain). */ export function imagePathsIn(text: string): string[] { const found = new Set(); const re = /(?:^|[\s`(\["'])((?:\.{0,2}\/)?[\w\-./]+\.(?:png|jpe?g|gif|webp|bmp|svg))(?=$|[\s`)\]"',.:;!?])/gim; for (const m of text.matchAll(re)) { // Skip URLs and markdown images (rendered anyway). const idx = m.index ?? 0; const before = text.slice(Math.max(0, idx - 2), idx + 1); if (m[1].includes("://") || before.includes("](") || text.slice(Math.max(0, idx - 10), idx + 1).includes("http")) continue; found.add(m[1]); } return [...found].slice(0, 8); } /** Thumbnails for image files mentioned in a message; missing files hide themselves. */ export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string }) { const paths = imagePathsIn(text.replace(/!\[[^\]]*\]\([^)]*\)/g, "")); if (paths.length === 0 || !baseDir) return null; return (
{paths.map((p) => { const url = projectFileUrl(p, baseDir)!; return ( {p} ((e.currentTarget.closest("figure") as HTMLElement).style.display = "none")} /> ); })}
); }