mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import hljs from 'highlight.js'
|
|
import { CopyButton } from '../shared/CopyButton'
|
|
|
|
type Props = {
|
|
code: string
|
|
language?: string
|
|
maxLines?: number
|
|
showLineNumbers?: boolean
|
|
}
|
|
|
|
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = true }: Props) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
const lines = code.split('\n')
|
|
const isTruncated = !expanded && lines.length > maxLines
|
|
const visibleCode = isTruncated ? lines.slice(0, maxLines).join('\n') : code
|
|
|
|
const highlightedLines = useMemo(() => {
|
|
return visibleCode.split('\n').map((line) => {
|
|
try {
|
|
if (language && hljs.getLanguage(language)) {
|
|
return hljs.highlight(line || ' ', { language, ignoreIllegals: true }).value
|
|
}
|
|
return hljs.highlightAuto(line || ' ').value
|
|
} catch {
|
|
return escapeHtml(line)
|
|
}
|
|
})
|
|
}, [language, visibleCode])
|
|
|
|
const startLine = 1
|
|
|
|
const languageLabel = language || 'code'
|
|
|
|
const visibleLines = visibleCode.split('\n')
|
|
|
|
const lineCountLabel = `${lines.length} ${lines.length === 1 ? 'line' : 'lines'}`
|
|
|
|
const showExpandToggle = lines.length > maxLines
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
|
|
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-1.5 text-[11px] text-[#57606a]">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-semibold uppercase tracking-[0.14em] text-[#57606a]">{languageLabel}</span>
|
|
<span>{lineCountLabel}</span>
|
|
</div>
|
|
<CopyButton
|
|
text={code}
|
|
className="rounded-md border border-[#d0d7de] bg-white px-2 py-1 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-[420px] overflow-auto">
|
|
<div className="min-w-full font-[var(--font-mono)] text-[12px] leading-[1.3]">
|
|
{visibleLines.map((line, index) => (
|
|
<div
|
|
key={`${startLine + index}-${line}`}
|
|
className="grid grid-cols-[3rem,minmax(0,1fr)] gap-0 hover:bg-[#f6f8fa]/50"
|
|
>
|
|
{showLineNumbers ? (
|
|
<span className="select-none border-r border-[#eaeef2] bg-[#fafbfc] px-2 py-px text-right text-[11px] text-[#8b949e]">
|
|
{startLine + index}
|
|
</span>
|
|
) : (
|
|
<span className="w-0" />
|
|
)}
|
|
<span
|
|
className="overflow-hidden bg-white px-4 py-px whitespace-pre-wrap break-words text-[#24292f]"
|
|
dangerouslySetInnerHTML={{ __html: highlightedLines[index] ?? escapeHtml(line) }}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{showExpandToggle && (
|
|
<button
|
|
onClick={() => setExpanded((value) => !value)}
|
|
className="w-full border-t border-[#d0d7de] bg-[#f6f8fa] py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#57606a] transition-colors hover:bg-[#eaeef2] hover:text-[#24292f]"
|
|
>
|
|
{expanded ? 'Collapse' : `Show ${lines.length - maxLines} more lines`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function escapeHtml(str: string): string {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|