Files
codecraft/web/src/components/Markdown.tsx
T
Richard ZinkandClaude Opus 5.5 a0d0e707f2 Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:03:26 +01:00

121 lines
5.0 KiB
TypeScript

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 (
<div className="svg-block">
<FileFigure name="inline.svg" size={new Blob([code]).size} mime="image/svg+xml">
<img src={url} alt="SVG" />
</FileFigure>
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
<Icon name={showCode ? "chevron-down" : "chevron-right"} /> SVG
</button>
{showCode && children}
</div>
);
}
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
return (
<div className="markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
components={{
a: (props) => <a {...props} target="_blank" rel="noreferrer" />,
img: ({ src, alt }) => {
const url = typeof src === "string" ? projectFileUrl(src, baseDir) : undefined;
if (!url) return <span>{alt}</span>;
// Non-image files (3D models, documents) become links.
if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) {
return <FileChip url={url} name={src} />;
}
const name = typeof src === "string" && !src.startsWith("data:") ? decodeURIComponent(src.split("?")[0]) : alt || undefined;
return (
<FileFigure url={url} name={name} mime={typeof src === "string" ? src.match(/^data:([^;,]+)/)?.[1] : undefined}>
<img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />
</FileFigure>
);
},
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("<svg"))) {
return (
<SvgBlock code={code}>
<pre>{children}</pre>
</SvgBlock>
);
}
return <pre>{children}</pre>;
},
}}
>
{text}
</ReactMarkdown>
</div>
);
});
/** Image files mentioned in a text (paths in backticks or plain). */
export function imagePathsIn(text: string): string[] {
const found = new Set<string>();
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 (
<div className="image-refs">
{paths.map((p) => {
const url = projectFileUrl(p, baseDir)!;
return (
<FileFigure key={p} url={url} name={p} className="thumb">
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.closest("figure") as HTMLElement).style.display = "none")} />
</FileFigure>
);
})}
</div>
);
}