feat(代码块): 添加长代码折叠/展开功能

- 超过60行代码自动折叠,预览前30行
- 添加渐变遮罩和展开按钮交互
- 显示总行数和剩余行数信息
- 展开状态下显示收起按钮
- 复制功能始终复制完整代码
- 优化代码区域布局使用 flex 结构
This commit is contained in:
gaoziman 2025-12-21 02:47:21 +08:00
parent 4eab17155e
commit b3d5a47072

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState, useMemo } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { Copy, Check, Eye } from 'lucide-react'; import { Copy, Check, Eye, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import Prism from 'prismjs'; import Prism from 'prismjs';
import { HtmlPreviewModal } from '@/components/ui/HtmlPreviewModal'; import { HtmlPreviewModal } from '@/components/ui/HtmlPreviewModal';
@ -30,6 +30,10 @@ interface CodeBlockProps {
language?: string; language?: string;
showLineNumbers?: boolean; showLineNumbers?: boolean;
className?: string; className?: string;
/** 折叠阈值,超过此行数自动折叠,默认 60 */
maxCollapsedLines?: number;
/** 折叠时预览的行数,默认 30 */
previewLines?: number;
} }
// 语言别名映射 // 语言别名映射
@ -51,9 +55,12 @@ export function CodeBlock({
language = 'text', language = 'text',
showLineNumbers = true, showLineNumbers = true,
className, className,
maxCollapsedLines = 60,
previewLines = 30,
}: CodeBlockProps) { }: CodeBlockProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// 规范化语言名称 // 规范化语言名称
const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase(); const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase();
@ -62,17 +69,33 @@ export function CodeBlock({
const isHtmlPreviewable = ['html', 'htm', 'markup'].includes(normalizedLanguage) || const isHtmlPreviewable = ['html', 'htm', 'markup'].includes(normalizedLanguage) ||
['html', 'htm'].includes(language.toLowerCase()); ['html', 'htm'].includes(language.toLowerCase());
const lines = code.split('\n');
const totalLines = lines.length;
const shouldCollapse = totalLines > maxCollapsedLines;
const remainingLines = totalLines - previewLines;
// 根据折叠状态获取显示的代码
const displayCode = useMemo(() => {
if (shouldCollapse && !isExpanded) {
return lines.slice(0, previewLines).join('\n');
}
return code;
}, [code, lines, shouldCollapse, isExpanded, previewLines]);
const displayLines = displayCode.split('\n');
// 使用 useMemo 缓存高亮后的 HTML避免频繁重新高亮 // 使用 useMemo 缓存高亮后的 HTML避免频繁重新高亮
const highlightedCode = useMemo(() => { const highlightedCode = useMemo(() => {
const grammar = Prism.languages[normalizedLanguage]; const grammar = Prism.languages[normalizedLanguage];
if (grammar) { if (grammar) {
return Prism.highlight(code, grammar, normalizedLanguage); return Prism.highlight(displayCode, grammar, normalizedLanguage);
} }
return code; return displayCode;
}, [code, normalizedLanguage]); }, [displayCode, normalizedLanguage]);
const handleCopy = async () => { const handleCopy = async () => {
try { try {
// 始终复制完整代码
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
@ -81,13 +104,15 @@ export function CodeBlock({
} }
}; };
const lines = code.split('\n'); const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
return ( return (
<div className={cn('relative group rounded overflow-hidden my-4', className)} <div className={cn('relative group rounded overflow-hidden my-4', className)}
style={{ boxShadow: 'var(--color-code-shadow)' }}> style={{ boxShadow: 'var(--color-code-shadow)' }}>
{/* 顶部工具栏 */} {/* 顶部工具栏 */}
<div className="flex items-center justify-between px-4 py-2.5 text-sm" <div className="flex items-center justify-between px-4 py-2.5 text-[0.9em]"
style={{ style={{
backgroundColor: 'var(--color-code-toolbar-bg)', backgroundColor: 'var(--color-code-toolbar-bg)',
color: 'var(--color-code-toolbar-text)', color: 'var(--color-code-toolbar-text)',
@ -106,6 +131,12 @@ export function CodeBlock({
style={{ backgroundColor: '#27c93f', boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1) inset' }} /> style={{ backgroundColor: '#27c93f', boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1) inset' }} />
</div> </div>
<span className="font-mono text-[13px]">{language || 'code'}</span> <span className="font-mono text-[13px]">{language || 'code'}</span>
{/* 显示总行数 */}
{shouldCollapse && (
<span className="text-[12px] opacity-60">
({totalLines} )
</span>
)}
</div> </div>
{/* 右侧:操作按钮 */} {/* 右侧:操作按钮 */}
@ -145,42 +176,95 @@ export function CodeBlock({
</div> </div>
{/* 代码区域 */} {/* 代码区域 */}
<div className="relative overflow-x-auto" <div style={{ backgroundColor: 'var(--color-code-bg)' }}>
style={{ backgroundColor: 'var(--color-code-bg)' }}> {/* 代码内容 - 使用 flex 布局 */}
<div className="flex overflow-x-auto">
{/* 行号列 */}
{showLineNumbers && ( {showLineNumbers && (
<div className="absolute left-0 top-0 bottom-0 w-12 select-none" <div
className="flex-shrink-0 w-12 select-none py-4 px-2 text-right"
style={{ style={{
backgroundColor: 'var(--color-code-bg)', backgroundColor: 'var(--color-code-bg)',
borderRight: '1px solid var(--color-code-line-border)' borderRight: '1px solid var(--color-code-line-border)'
}}> }}
<div className="py-4 px-2 text-right"> >
{lines.map((_, index) => ( {displayLines.map((_, index) => (
<div <div
key={index} key={index}
className="text-sm font-mono leading-6" className="font-mono leading-6"
style={{ color: 'var(--color-code-line-number)' }} style={{ color: 'var(--color-code-line-number)' }}
> >
{index + 1} {index + 1}
</div> </div>
))} ))}
</div> </div>
</div>
)} )}
{/* 代码列 */}
<pre <pre
className={cn( className="flex-1 p-4 overflow-x-auto"
'p-4 overflow-x-auto',
showLineNumbers && 'pl-16'
)}
style={{ margin: 0, background: 'transparent' }} style={{ margin: 0, background: 'transparent' }}
> >
<code <code
className={`language-${normalizedLanguage} text-sm leading-6`} className={`language-${normalizedLanguage} leading-6`}
style={{ color: 'var(--color-code-text)' }} style={{ color: 'var(--color-code-text)' }}
dangerouslySetInnerHTML={{ __html: highlightedCode }} dangerouslySetInnerHTML={{ __html: highlightedCode }}
/> />
</pre> </pre>
</div> </div>
{/* 渐变遮罩和展开按钮(仅在折叠状态下显示) */}
{shouldCollapse && !isExpanded && (
<>
{/* 渐变遮罩 */}
<div
className="h-16 -mt-16 pointer-events-none relative z-10"
style={{
background: `linear-gradient(to bottom, transparent, var(--color-code-bg))`
}}
/>
{/* 展开按钮 */}
<div
className="py-3 text-center cursor-pointer transition-colors hover:opacity-80"
style={{ backgroundColor: 'var(--color-code-bg)' }}
onClick={toggleExpand}
>
<button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-[0.9em] font-medium transition-colors"
style={{
color: 'var(--color-primary)',
backgroundColor: 'var(--color-primary-alpha)'
}}
>
<ChevronDown size={16} />
<span> {remainingLines} </span>
</button>
</div>
</>
)}
{/* 收起按钮(仅在展开状态下显示) */}
{shouldCollapse && isExpanded && (
<div
className="py-3 text-center cursor-pointer transition-colors"
style={{
backgroundColor: 'var(--color-code-bg)',
borderTop: '1px solid var(--color-code-line-border)'
}}
onClick={toggleExpand}
>
<button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-[0.9em] font-medium transition-colors"
style={{
color: 'var(--color-code-toolbar-text)'
}}
>
<ChevronUp size={16} />
<span></span>
</button>
</div>
)}
</div>
{/* HTML 预览模态框 */} {/* HTML 预览模态框 */}
{isHtmlPreviewable && ( {isHtmlPreviewable && (
<HtmlPreviewModal <HtmlPreviewModal