Spaces:
Sleeping
Sleeping
| import { useState, useEffect, memo, useMemo } from "react"; | |
| import { haptic } from "@/lib/haptic"; | |
| import { Copy, Check, ChevronDown, ChevronRight } from "lucide-react"; | |
| import CodeRunner from "./CodeRunner"; | |
| // ── Shiki: syntax highlight via esm.sh CDN (zero bundle impact) ─────────────── | |
| // Sostituisce highlight.js (969KB chunk) con Shiki (~200KB, tree-shakeable). | |
| // Shiki usa inline color styles — nessun CSS esterno necessario. | |
| // Singleton: il highlighter viene creato una sola volta e riusato per tutti i blocchi. | |
| const _SHIKI_ESM = "https://esm.sh/shiki@1.29.2?target=es2020"; | |
| const _SHIKI_THEME = "one-dark-pro"; | |
| const _SHIKI_LANGS = [ | |
| "javascript", "typescript", "jsx", "tsx", | |
| "python", "html", "css", "json", "bash", "shell", | |
| "markdown", "yaml", "sql", "rust", "go", | |
| "java", "cpp", "c", "kotlin", "swift", "php", | |
| "ruby", "text", | |
| ]; | |
| // Alias: identifiers usati nel progetto → nomi Shiki canonici | |
| const _SHIKI_ALIAS: Record<string, string> = { | |
| js: "javascript", ts: "typescript", | |
| sh: "bash", py: "python", | |
| md: "markdown", rs: "rust", | |
| rb: "ruby", kt: "kotlin", yml: "yaml", | |
| toml: "text", xml: "text", | |
| }; | |
| let _shikiP: Promise<any> | null = null; | |
| function _getShiki(): Promise<any> { | |
| if (!_shikiP) { | |
| _shikiP = (async () => { | |
| const { createHighlighter } = await import(_SHIKI_ESM as any); | |
| return createHighlighter({ themes: [_SHIKI_THEME], langs: _SHIKI_LANGS }); | |
| })().catch(() => null); // null = fallback a plain text | |
| } | |
| return _shikiP!; | |
| } | |
| /** Estrae l'innerHTML del <code> dal HTML prodotto da Shiki. */ | |
| function _extractCode(shikiHtml: string): string { | |
| const m = shikiHtml.match(/<code[^>]*>([\s\S]*?)<\/code>/); | |
| return m ? m[1] : shikiHtml; | |
| } | |
| interface CodeBlockProps { | |
| code: string; | |
| language?: string; | |
| onExecuteResult?: (result: string) => void; | |
| } | |
| // ── S56: Lang metadata con colori per categoria ────────────────────────── | |
| const LANG_META: Record<string, { label: string; color: string }> = { | |
| js: { label: "JavaScript", color: "#f7df1e" }, | |
| javascript: { label: "JavaScript", color: "#f7df1e" }, | |
| jsx: { label: "JSX", color: "#61dafb" }, | |
| ts: { label: "TypeScript", color: "#3178c6" }, | |
| typescript: { label: "TypeScript", color: "#3178c6" }, | |
| tsx: { label: "TSX", color: "#3178c6" }, | |
| py: { label: "Python", color: "#3572a5" }, | |
| python: { label: "Python", color: "#3572a5" }, | |
| sh: { label: "Shell", color: "#4ec9b0" }, | |
| bash: { label: "Bash", color: "#4ec9b0" }, | |
| css: { label: "CSS", color: "#264de4" }, | |
| html: { label: "HTML", color: "#e34c26" }, | |
| json: { label: "JSON", color: "#cbcb41" }, | |
| yaml: { label: "YAML", color: "#cb171e" }, | |
| toml: { label: "TOML", color: "#9c4121" }, | |
| xml: { label: "XML", color: "#e37933" }, | |
| sql: { label: "SQL", color: "#e38c00" }, | |
| rs: { label: "Rust", color: "#dea584" }, | |
| rust: { label: "Rust", color: "#dea584" }, | |
| go: { label: "Go", color: "#00add8" }, | |
| java: { label: "Java", color: "#b07219" }, | |
| cpp: { label: "C++", color: "#f34b7d" }, | |
| c: { label: "C", color: "#555555" }, | |
| rb: { label: "Ruby", color: "#701516" }, | |
| kt: { label: "Kotlin", color: "#a97bff" }, | |
| swift: { label: "Swift", color: "#f05138" }, | |
| php: { label: "PHP", color: "#4f5d95" }, | |
| md: { label: "Markdown", color: "#083fa1" }, | |
| markdown: { label: "Markdown", color: "#083fa1" }, | |
| }; | |
| function getLangMeta(lang: string) { | |
| return LANG_META[lang.toLowerCase()] ?? { | |
| label: lang.charAt(0).toUpperCase() + lang.slice(1) || "Text", | |
| color: "rgba(160,160,200,0.5)", | |
| }; | |
| } | |
| const GUTTER_MIN = 4; // mostra numeri riga solo se ≥ 4 righe | |
| // ── Gutter component ────────────────────────────────────────────────────── | |
| function Gutter({ count }: { count: number }) { | |
| return ( | |
| <div aria-hidden="true" style={{ | |
| display: "flex", flexDirection: "column", alignItems: "flex-end", | |
| padding: "14px 10px 14px 10px", minWidth: 34, | |
| userSelect: "none", flexShrink: 0, | |
| borderRight: "1px solid rgba(255,255,255,0.05)", | |
| background: "rgba(0,0,0,0.12)", | |
| }}> | |
| {Array.from({ length: count }, (_, i) => ( | |
| <span key={i} style={{ | |
| fontSize: "0.72rem", lineHeight: 1.65, | |
| color: "rgba(150,150,175,0.28)", | |
| fontFamily: "'JetBrains Mono',Menlo,monospace", | |
| fontVariantNumeric: "tabular-nums", | |
| }}> | |
| {i + 1} | |
| </span> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| const CodeBlock = memo(({ code, language = "", onExecuteResult }: CodeBlockProps) => { | |
| const [copied, setCopied] = useState(false); | |
| const [collapsed, setCollapsed] = useState(false); | |
| const [highlighted, setHighlighted] = useState<string | null>(null); | |
| const lineCount = useMemo(() => (code.match(/\n/g) || []).length + 1, [code]); | |
| const isExec = language === "execute"; | |
| const meta = getLangMeta(language || "text"); | |
| const showGutter = lineCount >= GUTTER_MIN && !isExec; | |
| useEffect(() => { | |
| if (isExec) return; | |
| let cancelled = false; | |
| const doHighlight = () => { | |
| _getShiki().then(highlighter => { | |
| if (cancelled) return; | |
| if (!highlighter) { setHighlighted(null); return; } | |
| // Risolvi alias (py → python, sh → bash, ecc.) | |
| const rawLang = (language || "").toLowerCase(); | |
| const resolvedLang = _SHIKI_ALIAS[rawLang] ?? rawLang; | |
| const safeLang = _SHIKI_LANGS.includes(resolvedLang) ? resolvedLang : "text"; | |
| try { | |
| const html = highlighter.codeToHtml(code, { lang: safeLang, theme: _SHIKI_THEME }); | |
| if (!cancelled) setHighlighted(_extractCode(html)); | |
| } catch { | |
| if (!cancelled) setHighlighted(null); | |
| } | |
| }).catch(() => { if (!cancelled) setHighlighted(null); }); | |
| }; | |
| // S225: Safari/iPhone — defer syntax highlight to browser idle time | |
| // evita di bloccare il main thread durante lo streaming di messaggi | |
| if (typeof requestIdleCallback !== "undefined") { | |
| const id = requestIdleCallback(doHighlight, { timeout: 2000 }); | |
| return () => { cancelled = true; cancelIdleCallback(id); }; | |
| } | |
| doHighlight(); | |
| return () => { cancelled = true; }; | |
| }, [code, language, isExec]); | |
| const copy = () => { | |
| navigator.clipboard.writeText(code).then(() => { | |
| haptic("copy"); // AUT-4: feedback aptico su Android PWA | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 1500); | |
| }).catch(() => {}); | |
| }; | |
| if (isExec) { | |
| return <CodeRunner code={code} onResult={onExecuteResult} />; | |
| } | |
| return ( | |
| <div style={{ | |
| borderRadius: 10, | |
| border: "1px solid rgba(255,255,255,0.07)", | |
| background: "rgba(0,0,0,0.35)", | |
| overflow: "hidden", | |
| margin: "0.5em 0 0.9em", | |
| fontSize: "0.82rem", | |
| }}> | |
| {/* Toolbar */} | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onClick={() => setCollapsed(c => !c)} | |
| onKeyDown={e => e.key === "Enter" && setCollapsed(c => !c)} | |
| style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| padding: "7px 12px", | |
| background: "rgba(255,255,255,0.03)", | |
| borderBottom: collapsed ? "none" : "1px solid rgba(255,255,255,0.06)", | |
| cursor: "pointer", | |
| userSelect: "none", | |
| gap: 8, | |
| }} | |
| > | |
| {/* Left: chevron + lang dot + label + line count */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}> | |
| {collapsed | |
| ? <ChevronRight size={13} style={{ color: "rgba(180,180,200,0.45)", flexShrink: 0 }} /> | |
| : <ChevronDown size={13} style={{ color: "rgba(180,180,200,0.45)", flexShrink: 0 }} /> | |
| } | |
| {/* Pallino colore linguaggio */} | |
| <span style={{ | |
| width: 8, height: 8, borderRadius: "50%", | |
| background: meta.color, flexShrink: 0, | |
| boxShadow: `0 0 5px ${meta.color}55`, | |
| }} /> | |
| <span style={{ | |
| fontSize: "0.72rem", fontWeight: 600, | |
| color: "rgba(220,220,240,0.75)", | |
| fontFamily: "monospace", letterSpacing: "0.02em", | |
| }}> | |
| {meta.label} | |
| </span> | |
| <span style={{ | |
| fontSize: "0.63rem", | |
| color: "rgba(140,140,170,0.4)", | |
| fontFamily: "monospace", | |
| }}> | |
| {lineCount} {lineCount === 1 ? "riga" : "righe"} | |
| </span> | |
| </div> | |
| {/* Right: copy button */} | |
| <button | |
| onClick={e => { e.stopPropagation(); copy(); }} | |
| title={copied ? "Copiato!" : "Copia codice"} | |
| style={{ | |
| all: "unset", | |
| cursor: "pointer", | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 5, | |
| padding: "3px 8px", | |
| borderRadius: 6, | |
| fontSize: "0.68rem", | |
| fontWeight: 500, | |
| color: copied ? "#7c7cff" : "rgba(160,160,200,0.55)", | |
| background: copied ? "rgba(124,124,255,0.08)" : "transparent", | |
| border: copied ? "1px solid rgba(124,124,255,0.18)" : "1px solid transparent", | |
| transition: "all 0.2s", | |
| flexShrink: 0, | |
| }} | |
| onMouseEnter={e => { | |
| if (!copied) { | |
| e.currentTarget.style.color = "rgba(200,200,230,0.85)"; | |
| e.currentTarget.style.background = "rgba(255,255,255,0.05)"; | |
| } | |
| }} | |
| onMouseLeave={e => { | |
| if (!copied) { | |
| e.currentTarget.style.color = "rgba(160,160,200,0.55)"; | |
| e.currentTarget.style.background = "transparent"; | |
| } | |
| }} | |
| > | |
| {copied | |
| ? <><Check size={11} /><span>Copiato</span></> | |
| : <><Copy size={11} /><span>Copia</span></> | |
| } | |
| </button> | |
| </div> | |
| {/* Code body + gutter */} | |
| {!collapsed && ( | |
| <div style={{ display: "flex", overflowX: "auto" }}> | |
| {showGutter && <Gutter count={lineCount} />} | |
| <div style={{ flex: 1, padding: "14px 16px" }}> | |
| {highlighted !== null ? ( | |
| <pre style={{ | |
| margin: 0, padding: 0, | |
| fontFamily: "'JetBrains Mono','Fira Code','Cascadia Code',Menlo,Monaco,Consolas,monospace", | |
| fontSize: "0.82rem", lineHeight: 1.65, | |
| color: "#d4d4d8", whiteSpace: "pre", | |
| }}> | |
| <code | |
| className={`language-${language || "text"}`} | |
| dangerouslySetInnerHTML={{ __html: highlighted }} | |
| /> | |
| </pre> | |
| ) : ( | |
| <pre style={{ | |
| margin: 0, padding: 0, | |
| fontFamily: "'JetBrains Mono','Fira Code',Menlo,Monaco,Consolas,monospace", | |
| fontSize: "0.82rem", lineHeight: 1.65, | |
| color: "#d4d4d8", whiteSpace: "pre", | |
| }}> | |
| <code>{code}</code> | |
| </pre> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }); | |
| CodeBlock.displayName = "CodeBlock"; | |
| export default CodeBlock; | |