Bloque de código para respuestas
Cabecera con nombre de archivo, números de línea, resaltado ligero sin librerías y copia con confirmación.
// Suma el total de una orden con impuestosexport function totalOrden(items: Item[], iva = 0.16) { const subtotal = items.reduce((sum, item) => sum + item.precio * item.cantidad, 0); return Math.round(subtotal * (1 + iva) * 100) / 100;}Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
/** Bloque de código para respuestas de un asistente: cabecera con lenguaje, números de línea, resaltado ligero y botón de copiar con confirmación. React + TypeScript, sin dependencias. Personaliza con --gcai-*. */
export type AiCodigoProps = {
code?: string;
language?: "ts" | "js" | "sql" | "json" | "bash";
filename?: string;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcaicd-root { box-sizing: border-box; width: 100%; max-width: 620px; overflow: hidden; border: 1px solid var(--gcai-line, #26272c); border-radius: 12px; background: #0d0d0f; color: var(--gcai-ink, #ededee); font: 400 14px/1.6 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
.gcaicd-root *, .gcaicd-root *::before, .gcaicd-root *::after { box-sizing: border-box; }
.gcaicd-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcaicd-root :focus-visible { outline: 2px solid var(--gcai-accent, #84b3ff); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcaicd-root * { transition-duration: .01ms !important; } }
.gcaicd-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 8px 8px 16px; border-bottom: 1px solid var(--gcai-line, #26272c); background: var(--gcai-surface, #111113); font: 500 12px/1 ui-sans-serif, system-ui, sans-serif; color: var(--gcai-muted, #9b9ca3); }
.gcaicd-copy { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: inherit; cursor: pointer; transition: background .15s, color .15s; }
.gcaicd-copy:hover { background: var(--gcai-well, #1a1b1e); color: var(--gcai-ink, #ededee); }
.gcaicd-copy[data-done="true"] { color: #8fe3b0; }
.gcaicd-body { display: grid; grid-template-columns: auto minmax(0, 1fr); overflow-x: auto; padding: 14px 0; }
.gcaicd-nums { padding: 0 14px 0 16px; text-align: right; color: #4d4e56; user-select: none; }
.gcaicd-code { margin: 0; padding: 0 16px 0 0; white-space: pre; }
.gcaicd-code ::selection { background: rgba(132, 179, 255, .35); }
.gcaicd-k { color: #c3a6ff; } .gcaicd-s { color: #9fd9a8; } .gcaicd-c { color: #6f7079; font-style: italic; } .gcaicd-n { color: #f0b27a; } .gcaicd-f { color: #84b3ff; }`;
const KEYWORDS: Record<string, string> = {
ts: "const let var function return if else for while import from export type interface async await new class extends true false null undefined",
js: "const let var function return if else for while import from export async await new class extends true false null undefined",
sql: "select from where group by order insert into values update set delete join left on and or not null as limit create table",
json: "true false null",
bash: "git npm npx cd ls echo export if then fi",
};
function highlight(line: string, lang: string): ReactNode[] {
const kw = new Set(KEYWORDS[lang].split(" "));
const re = /(\/\/.*|#.*|--.*)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`[^`]*`)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_][\w]*)(?=\()|([A-Za-z_][\w]*)/g;
const out: ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(line))) {
if (m.index > last) out.push(line.slice(last, m.index));
const [text, comment, str, num, fn, word] = m;
if (comment) out.push(<span key={m.index} className="gcaicd-c">{text}</span>);
else if (str) out.push(<span key={m.index} className="gcaicd-s">{text}</span>);
else if (num) out.push(<span key={m.index} className="gcaicd-n">{text}</span>);
else if (fn) out.push(kw.has(fn.toLowerCase()) ? <span key={m.index} className="gcaicd-k">{text}</span> : <span key={m.index} className="gcaicd-f">{text}</span>);
else if (word && kw.has(lang === "sql" ? word.toLowerCase() : word)) out.push(<span key={m.index} className="gcaicd-k">{text}</span>);
else out.push(text);
last = m.index + text.length;
}
if (last < line.length) out.push(line.slice(last));
return out;
}
const SAMPLE = `// Suma el total de una orden con impuestos
export function totalOrden(items: Item[], iva = 0.16) {
const subtotal = items.reduce((sum, item) => sum + item.precio * item.cantidad, 0);
return Math.round(subtotal * (1 + iva) * 100) / 100;
}`;
export function AiCodigo({ code = SAMPLE, language = "ts", filename = "orden.ts", className = "", style }: AiCodigoProps) {
const [done, setDone] = useState(false);
const timer = useRef(0);
useEffect(() => () => window.clearTimeout(timer.current), []);
const lines = code.split("\n");
async function copy() {
try {
await navigator.clipboard.writeText(code);
setDone(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setDone(false), 1800);
} catch {
setDone(false);
}
}
return (
<figure className={`gcaicd-root ${className}`} style={{ margin: 0, ...style }}>
<style>{CSS}</style>
<figcaption className="gcaicd-head">
<span>{filename}</span>
<button className="gcaicd-copy" type="button" data-done={done} onClick={copy} aria-label={done ? "Código copiado" : "Copiar código"}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{done ? <path d="m5 12.5 4.5 4.5L19 7.5" /> : <><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V6a2 2 0 0 1 2-2h8" /></>}</svg>
{done ? "Copiado" : "Copiar"}
</button>
</figcaption>
<div className="gcaicd-body">
<div className="gcaicd-nums" aria-hidden="true">
{lines.map((_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
<pre className="gcaicd-code">
<code>
{lines.map((line, i) => (
<div key={i}>{highlight(line, language) || " "}{line === "" ? " " : null}</div>
))}
</code>
</pre>
</div>
</figure>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-codigoEjemplo de uso de Bloque de código para respuestas
Después de copiar ai-codigo.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { AiCodigo } from "./ai-codigo";
export default function Ejemplo() {
return <AiCodigo language="ts" code="Ejemplo" />;
}Propiedades de AiCodigo
AiCodigo acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
code? | string | Opcional. |
language? | "ts" | "js" | "sql" | "json" | "bash" | Opcional. |
filename? | string | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Variantes disponibles
language:ts,js,sql,json,bash
Personaliza Bloque de código para respuestas con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--gcai-line | #26272c |
--gcai-ink | #ededee |
--gcai-accent | #84b3ff |
--gcai-surface | #111113 |
--gcai-muted | #9b9ca3 |
--gcai-well | #1a1b1e |
Accesibilidad y rendimiento
El archivo tiene 109 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Bloque de código para respuestas por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Es un componente de cliente de Next.js: incluye la directiva "use client".