mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-01 08:31:04 +08:00
278 lines
7.3 KiB
TypeScript
278 lines
7.3 KiB
TypeScript
import { CheckOutlined, DownOutlined, RightOutlined } from '@ant-design/icons'
|
|
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
|
import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvider'
|
|
import { useSettings } from '@renderer/hooks/useSettings'
|
|
import React, { memo, useEffect, useRef, useState } from 'react'
|
|
import DOMPurify from 'dompurify'
|
|
import { useTranslation } from 'react-i18next'
|
|
import styled from 'styled-components'
|
|
|
|
import Artifacts from './Artifacts'
|
|
import Mermaid from './Mermaid'
|
|
import SvgPreview from './SvgPreview'
|
|
|
|
interface CodeBlockProps {
|
|
children: string
|
|
className?: string
|
|
[key: string]: any
|
|
}
|
|
|
|
const CollapseIcon: React.FC<{ expanded: boolean; onClick: () => void }> = ({ expanded, onClick }) => {
|
|
return (
|
|
<CollapseIconWrapper onClick={onClick}>
|
|
{expanded ? <DownOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
|
|
</CollapseIconWrapper>
|
|
)
|
|
}
|
|
|
|
const ExpandButton: React.FC<{
|
|
isExpanded: boolean
|
|
onClick: () => void
|
|
showButton: boolean
|
|
}> = ({ isExpanded, onClick, showButton }) => {
|
|
if (!showButton) return null
|
|
|
|
return (
|
|
<ExpandButtonWrapper onClick={onClick}>
|
|
<div className="button-text">{isExpanded ? '收起' : '展开'}</div>
|
|
</ExpandButtonWrapper>
|
|
)
|
|
}
|
|
const ALLOWED_TAGS = ['sub'] // 允许的HTML标签
|
|
|
|
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
|
const match = /language-(\w+)/.exec(className || '')
|
|
const { codeShowLineNumbers, fontSize, codeCollapsible } = useSettings()
|
|
const language = match?.[1] ?? 'text'
|
|
const [html, setHtml] = useState<string>('')
|
|
const { codeToHtml } = useSyntaxHighlighter()
|
|
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
|
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
|
|
const codeContentRef = useRef<HTMLDivElement>(null)
|
|
|
|
const showFooterCopyButton = children && children.length > 500 && !codeCollapsible
|
|
|
|
useEffect(() => {
|
|
const loadHighlightedCode = async () => {
|
|
const highlightedHtml = await codeToHtml(children, language)
|
|
setHtml(highlightedHtml)
|
|
}
|
|
loadHighlightedCode()
|
|
}, [children, language, codeToHtml])
|
|
|
|
useEffect(() => {
|
|
if (codeContentRef.current) {
|
|
setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350)
|
|
}
|
|
}, [html])
|
|
|
|
useEffect(() => {
|
|
if (!codeCollapsible) {
|
|
setIsExpanded(true)
|
|
setShouldShowExpandButton(false)
|
|
} else {
|
|
setIsExpanded(!codeCollapsible)
|
|
if (codeContentRef.current) {
|
|
setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350)
|
|
}
|
|
}
|
|
}, [codeCollapsible])
|
|
|
|
if (language === 'mermaid') {
|
|
return <Mermaid chart={children} />
|
|
}
|
|
|
|
if (language === 'svg') {
|
|
return (
|
|
<CodeBlockWrapper className="code-block">
|
|
<CodeHeader>
|
|
<CodeLanguage>{'<SVG>'}</CodeLanguage>
|
|
<CopyButton text={children} />
|
|
</CodeHeader>
|
|
<SvgPreview>{children}</SvgPreview>
|
|
</CodeBlockWrapper>
|
|
)
|
|
}
|
|
|
|
return match ? (
|
|
<CodeBlockWrapper className="code-block">
|
|
<CodeHeader>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
{codeCollapsible && shouldShowExpandButton && (
|
|
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
|
|
)}
|
|
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
|
|
</div>
|
|
<CopyButton text={children} />
|
|
</CodeHeader>
|
|
<CodeContent
|
|
ref={codeContentRef}
|
|
isShowLineNumbers={codeShowLineNumbers}
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
style={{
|
|
border: '0.5px solid var(--color-code-background)',
|
|
borderTopLeftRadius: 0,
|
|
borderTopRightRadius: 0,
|
|
marginTop: 0,
|
|
fontSize: fontSize - 1,
|
|
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
|
|
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible',
|
|
position: 'relative'
|
|
}}
|
|
/>
|
|
{codeCollapsible && (
|
|
<ExpandButton
|
|
isExpanded={isExpanded}
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
showButton={shouldShowExpandButton}
|
|
/>
|
|
)}
|
|
{showFooterCopyButton && (
|
|
<CodeFooter>
|
|
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
|
|
</CodeFooter>
|
|
)}
|
|
{language === 'html' && children?.includes('</html>') && <Artifacts html={children} />}
|
|
</CodeBlockWrapper>
|
|
) : (
|
|
<code
|
|
className={className}
|
|
dangerouslySetInnerHTML={{
|
|
__html: DOMPurify.sanitize(children, {
|
|
ALLOWED_TAGS,
|
|
ALLOWED_ATTR: [] // 不允许任何属性
|
|
})
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
|
|
const [copied, setCopied] = useState(false)
|
|
const { t } = useTranslation()
|
|
|
|
const onCopy = () => {
|
|
navigator.clipboard.writeText(text)
|
|
window.message.success({ content: t('message.copied'), key: 'copy-code' })
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
return copied ? (
|
|
<CheckOutlined style={{ color: 'var(--color-primary)', ...style }} />
|
|
) : (
|
|
<CopyIcon className="copy" style={style} onClick={onCopy} />
|
|
)
|
|
}
|
|
|
|
const CodeBlockWrapper = styled.div``
|
|
|
|
const CodeContent = styled.div<{ isShowLineNumbers: boolean }>`
|
|
.shiki {
|
|
padding: 1em;
|
|
}
|
|
|
|
${(props) =>
|
|
props.isShowLineNumbers &&
|
|
`
|
|
code {
|
|
counter-reset: step;
|
|
counter-increment: step 0;
|
|
}
|
|
|
|
code .line::before {
|
|
content: counter(step);
|
|
counter-increment: step;
|
|
width: 1rem;
|
|
margin-right: 1rem;
|
|
display: inline-block;
|
|
text-align: right;
|
|
opacity: 0.35;
|
|
}
|
|
`}
|
|
`
|
|
|
|
const CodeHeader = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
color: var(--color-text);
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
height: 34px;
|
|
padding: 0 10px;
|
|
border-top-left-radius: 8px;
|
|
border-top-right-radius: 8px;
|
|
.copy {
|
|
cursor: pointer;
|
|
color: var(--color-text-3);
|
|
transition: color 0.3s;
|
|
}
|
|
.copy:hover {
|
|
color: var(--color-text-1);
|
|
}
|
|
`
|
|
|
|
const CodeLanguage = styled.div`
|
|
font-weight: bold;
|
|
`
|
|
|
|
const CodeFooter = styled.div`
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
position: relative;
|
|
.copy {
|
|
cursor: pointer;
|
|
color: var(--color-text-3);
|
|
transition: color 0.3s;
|
|
}
|
|
.copy:hover {
|
|
color: var(--color-text-1);
|
|
}
|
|
`
|
|
|
|
const ExpandButtonWrapper = styled.div`
|
|
position: relative;
|
|
cursor: pointer;
|
|
height: 25px;
|
|
margin-top: -25px;
|
|
|
|
.button-text {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
text-align: center;
|
|
padding: 8px;
|
|
color: var(--color-text-3);
|
|
z-index: 1;
|
|
transition: color 0.2s;
|
|
font-size: 12px;
|
|
}
|
|
|
|
&:hover .button-text {
|
|
color: var(--color-text-1);
|
|
}
|
|
`
|
|
|
|
const CollapseIconWrapper = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
color: var(--color-text-3);
|
|
transition: all 0.2s ease;
|
|
|
|
&:hover {
|
|
background-color: var(--color-background-soft);
|
|
color: var(--color-text-1);
|
|
}
|
|
`
|
|
|
|
export default memo(CodeBlock)
|