import { Icon } from "./Icon";
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 (
{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 (
);
},
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("
);
});
/** 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 (
((e.currentTarget.closest("figure") as HTMLElement).style.display = "none")} />
);
})}
);
}