Add a persistent wrap/nowrap toggle button in the code block header bar, consistent with the existing tool call content viewer. Persisted in localStorage. Default is nowrap (existing behavior).
150 lines
5.6 KiB
TypeScript
150 lines
5.6 KiB
TypeScript
import { useState, useCallback, type HTMLAttributes, type ReactElement } from 'react';
|
|
import { Check, Copy, Hash, WrapText, AlignLeft } from 'lucide-react';
|
|
|
|
/** Extract the language from the nested <code> element's className (e.g. "language-ts"). */
|
|
function extractLanguage(children: React.ReactNode): string | null {
|
|
const codeEl = children as ReactElement<{ className?: string }> | undefined;
|
|
const className = codeEl?.props?.className;
|
|
if (typeof className !== 'string') return null;
|
|
const match = className.match(/language-(\S+)/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/** Pretty-print common language identifiers. */
|
|
const LANGUAGE_LABELS: Record<string, string> = {
|
|
js: 'JavaScript',
|
|
jsx: 'JSX',
|
|
ts: 'TypeScript',
|
|
tsx: 'TSX',
|
|
py: 'Python',
|
|
rb: 'Ruby',
|
|
rs: 'Rust',
|
|
go: 'Go',
|
|
sh: 'Shell',
|
|
bash: 'Bash',
|
|
zsh: 'Zsh',
|
|
yml: 'YAML',
|
|
yaml: 'YAML',
|
|
md: 'Markdown',
|
|
json: 'JSON',
|
|
html: 'HTML',
|
|
css: 'CSS',
|
|
sql: 'SQL',
|
|
dockerfile: 'Dockerfile',
|
|
toml: 'TOML',
|
|
};
|
|
|
|
function formatLanguage(lang: string): string {
|
|
return LANGUAGE_LABELS[lang] || lang;
|
|
}
|
|
|
|
/**
|
|
* Custom <pre> renderer for ReactMarkdown.
|
|
* Wraps code blocks with a language label and a floating copy button.
|
|
*/
|
|
const LINE_NUMBER_KEY = 'pinchchat-line-numbers';
|
|
const WRAP_KEY = 'pinchchat-code-wrap';
|
|
const LINE_THRESHOLD = 3; // Only show line numbers for blocks with more than this many lines
|
|
|
|
export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
|
|
const [copied, setCopied] = useState(false);
|
|
const [showLineNumbers, setShowLineNumbers] = useState(() => {
|
|
const stored = localStorage.getItem(LINE_NUMBER_KEY);
|
|
return stored === null ? true : stored === 'true';
|
|
});
|
|
const [wordWrap, setWordWrap] = useState(() => {
|
|
const stored = localStorage.getItem(WRAP_KEY);
|
|
return stored === 'true';
|
|
});
|
|
const language = extractLanguage(props.children);
|
|
|
|
const code = (props.children as ReactElement<{ children?: string }> | undefined)?.props?.children;
|
|
const lines = typeof code === 'string' ? code.replace(/\n$/, '').split('\n') : [];
|
|
const hasEnoughLines = lines.length > LINE_THRESHOLD;
|
|
|
|
const handleCopy = useCallback(() => {
|
|
if (typeof code === 'string') {
|
|
navigator.clipboard.writeText(code).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
});
|
|
}
|
|
}, [code]);
|
|
|
|
const toggleLineNumbers = useCallback(() => {
|
|
setShowLineNumbers(prev => {
|
|
const next = !prev;
|
|
localStorage.setItem(LINE_NUMBER_KEY, String(next));
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const toggleWrap = useCallback(() => {
|
|
setWordWrap(prev => {
|
|
const next = !prev;
|
|
localStorage.setItem(WRAP_KEY, String(next));
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const shouldShowNumbers = showLineNumbers && hasEnoughLines;
|
|
|
|
return (
|
|
<div className="group/code relative">
|
|
{language && (
|
|
<div className="flex items-center justify-between px-4 py-1.5 bg-pc-elevated/80 border-b border-pc-border rounded-t-lg text-[11px] text-pc-text-muted font-mono select-none">
|
|
<span>{formatLanguage(language)}</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={toggleWrap}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-pc-border/40 transition-colors text-pc-text-muted hover:text-pc-text-secondary"
|
|
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
|
type="button"
|
|
>
|
|
{wordWrap ? <AlignLeft className="h-3 w-3" /> : <WrapText className="h-3 w-3" />}
|
|
</button>
|
|
{hasEnoughLines && (
|
|
<button
|
|
onClick={toggleLineNumbers}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-pc-border/40 transition-colors text-pc-text-muted hover:text-pc-text-secondary"
|
|
title={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
|
|
type="button"
|
|
>
|
|
<Hash className="h-3 w-3" />
|
|
<span className="text-[10px]">{lines.length}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{shouldShowNumbers ? (
|
|
<div className={`flex ${language ? 'rounded-t-none' : 'rounded-lg'} overflow-hidden`}>
|
|
<div
|
|
className="flex-shrink-0 select-none text-right pr-3 pl-3 py-4 text-[11px] leading-[1.7142857] font-mono text-pc-text-muted/40 bg-pc-elevated/40 border-r border-pc-border/50"
|
|
aria-hidden="true"
|
|
>
|
|
{lines.map((_, i) => (
|
|
<div key={i}>{i + 1}</div>
|
|
))}
|
|
</div>
|
|
<pre {...props} className={`${props.className || ''} flex-1 !rounded-none !mt-0 min-w-0`} style={wordWrap ? { whiteSpace: 'pre-wrap', overflowWrap: 'break-word', wordBreak: 'break-word' } : undefined} />
|
|
</div>
|
|
) : (
|
|
<pre {...props} className={`${props.className || ''} ${language ? '!rounded-t-none !mt-0' : ''}`} style={wordWrap ? { whiteSpace: 'pre-wrap', overflowWrap: 'break-word', wordBreak: 'break-word' } : undefined} />
|
|
)}
|
|
<button
|
|
onClick={handleCopy}
|
|
className="absolute top-2 right-2 p-1.5 rounded-lg bg-pc-elevated/60 hover:bg-pc-elevated/80 border border-pc-border-strong text-pc-text-secondary hover:text-pc-text opacity-0 group-hover/code:opacity-100 transition-opacity duration-150"
|
|
title="Copy code"
|
|
aria-label="Copy code to clipboard"
|
|
type="button"
|
|
>
|
|
{copied
|
|
? <Check className="h-3.5 w-3.5 text-green-400" />
|
|
: <Copy className="h-3.5 w-3.5" />
|
|
}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|