feat(代码块): 添加长代码折叠/展开功能
- 超过60行代码自动折叠,预览前30行 - 添加渐变遮罩和展开按钮交互 - 显示总行数和剩余行数信息 - 展开状态下显示收起按钮 - 复制功能始终复制完整代码 - 优化代码区域布局使用 flex 结构
This commit is contained in:
parent
4eab17155e
commit
b3d5a47072
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user