refactor(CodePreview): line numbers as elements

This commit is contained in:
one 2025-06-27 02:41:28 +08:00
parent 766897e733
commit 16772c1d37

View File

@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { uuid } from '@renderer/utils' import { uuid } from '@renderer/utils'
import { getReactStyleFromToken } from '@renderer/utils/shiki' import { getReactStyleFromToken } from '@renderer/utils/shiki'
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react' import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core' import { ThemedToken } from 'shiki/core'
import styled from 'styled-components' import styled from 'styled-components'
@ -154,12 +154,18 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
setTimeout(highlightCode, 0) setTimeout(highlightCode, 0)
}, [isInViewport, highlightCode]) }, [isInViewport, highlightCode])
useEffect(() => { const lastDigitsRef = useRef(1)
useLayoutEffect(() => {
const container = codeContainerRef.current const container = codeContainerRef.current
if (!container || !codeShowLineNumbers) return if (!container || !codeShowLineNumbers) return
const digits = Math.max(tokenLines.length.toString().length, 1) const digits = Math.max(tokenLines.length.toString().length, 1)
container.style.setProperty('--line-digits', digits.toString()) if (digits === lastDigitsRef.current) return
const gutterWidth = digits * 0.6
container.style.setProperty('--gutter-width', `${gutterWidth}rem`)
lastDigitsRef.current = digits
}, [codeShowLineNumbers, tokenLines.length]) }, [codeShowLineNumbers, tokenLines.length])
const hasHighlightedCode = tokenLines.length > 0 const hasHighlightedCode = tokenLines.length > 0
@ -167,7 +173,6 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
return ( return (
<ContentContainer <ContentContainer
ref={codeContainerRef} ref={codeContainerRef}
$lineNumbers={codeShowLineNumbers}
$wrap={codeWrappable && !isUnwrapped} $wrap={codeWrappable && !isUnwrapped}
$fadeIn={hasHighlightedCode} $fadeIn={hasHighlightedCode}
style={{ style={{
@ -175,7 +180,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none' maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
}}> }}>
{hasHighlightedCode ? ( {hasHighlightedCode ? (
<ShikiTokensRenderer language={language} tokenLines={tokenLines} /> <ShikiTokensRenderer language={language} tokenLines={tokenLines} showLineNumbers={codeShowLineNumbers} />
) : ( ) : (
<CodePlaceholder>{children}</CodePlaceholder> <CodePlaceholder>{children}</CodePlaceholder>
)} )}
@ -188,43 +193,47 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
* *
* 便 virtual list * 便 virtual list
*/ */
const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo( const ShikiTokensRenderer: React.FC<{
({ language, tokenLines }) => { language: string
const { getShikiPreProperties } = useCodeStyle() tokenLines: ThemedToken[][]
const rendererRef = useRef<HTMLPreElement>(null) showLineNumbers: boolean
}> = memo(({ language, tokenLines, showLineNumbers }) => {
const { getShikiPreProperties } = useCodeStyle()
const rendererRef = useRef<HTMLPreElement>(null)
// 设置 pre 标签属性 // 设置 pre 标签属性
useEffect(() => { useEffect(() => {
getShikiPreProperties(language).then((properties) => { getShikiPreProperties(language).then((properties) => {
const pre = rendererRef.current const pre = rendererRef.current
if (pre) { if (pre) {
pre.className = properties.class pre.className = properties.class
pre.style.cssText = properties.style pre.style.cssText = properties.style
pre.tabIndex = properties.tabindex pre.tabIndex = properties.tabindex
} }
}) })
}, [language, getShikiPreProperties]) }, [language, getShikiPreProperties])
return ( return (
<pre className="shiki" ref={rendererRef}> <pre className="shiki" ref={rendererRef}>
<code> <code>
{tokenLines.map((lineTokens, lineIndex) => ( {tokenLines.map((lineTokens, lineIndex) => (
<span key={`line-${lineIndex}`} className="line"> <span key={`line-${lineIndex}`} className="line">
{showLineNumbers && <span className="line-number">{lineIndex + 1}</span>}
<span className="line-content">
{lineTokens.map((token, tokenIndex) => ( {lineTokens.map((token, tokenIndex) => (
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}> <span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
{token.content} {token.content}
</span> </span>
))} ))}
</span> </span>
))} </span>
</code> ))}
</pre> </code>
) </pre>
} )
) })
const ContentContainer = styled.div<{ const ContentContainer = styled.div<{
$lineNumbers: boolean
$wrap: boolean $wrap: boolean
$fadeIn: boolean $fadeIn: boolean
}>` }>`
@ -233,9 +242,8 @@ const ContentContainer = styled.div<{
border-radius: inherit; border-radius: inherit;
margin-top: 0; margin-top: 0;
/* 动态宽度计算 */ /* gutter 宽度默认值 */
--line-digits: 0; --gutter-width: 0.6rem;
--gutter-width: max(calc(var(--line-digits) * 0.7rem), 2.1rem);
.shiki { .shiki {
padding: 1em; padding: 1em;
@ -246,38 +254,35 @@ const ContentContainer = styled.div<{
flex-direction: column; flex-direction: column;
.line { .line {
display: block; display: flex;
align-items: flex-start;
min-height: 1.3rem; min-height: 1.3rem;
padding-left: ${(props) => (props.$lineNumbers ? 'var(--gutter-width)' : '0')};
* { .line-number {
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; width: var(--gutter-width);
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; text-align: right;
opacity: 0.35;
margin-right: 1rem;
user-select: none;
flex-shrink: 0;
overflow: hidden;
line-height: inherit;
font-family: inherit;
font-variant-numeric: tabular-nums;
}
.line-content {
flex: 1;
* {
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
}
} }
} }
} }
} }
${(props) =>
props.$lineNumbers &&
`
code {
counter-reset: step;
counter-increment: step 0;
position: relative;
}
code .line::before {
content: counter(step);
counter-increment: step;
width: 1rem;
position: absolute;
left: 0;
text-align: right;
opacity: 0.35;
}
`}
@keyframes contentFadeIn { @keyframes contentFadeIn {
from { from {
opacity: 0; opacity: 0;