perf(CodePreview): virtual list for shiki code block (#7621)

* perf(CodePreview: virtual list for shiki code block

- move code highlighting to a hook
- use @tanstack/react-virtual dynamic list for CodePreview
- highlight visible items on demand

* refactor: change absolute position to relative position

* refactor: update shiki styles, set scrollbar color for shiki themes
This commit is contained in:
one 2025-07-04 03:11:30 +08:00 committed by GitHub
parent e3057f90ea
commit d7f2ebcb6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 366 additions and 254 deletions

View File

@ -112,6 +112,7 @@
"@shikijs/markdown-it": "^3.7.0",
"@swc/plugin-styled-components": "^7.1.5",
"@tanstack/react-query": "^5.27.0",
"@tanstack/react-virtual": "^3.13.12",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",

View File

@ -323,6 +323,11 @@ mjx-container {
margin-top: -2px;
}
/* Shiki 相关样式 */
.shiki {
font-family: var(--code-font-family);
}
/* CodeMirror 相关样式 */
.cm-editor {
border-radius: inherit;

View File

@ -1,11 +1,16 @@
:root {
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
--color-scrollbar-thumb-dark: rgba(255, 255, 255, 0.15);
--color-scrollbar-thumb-dark-hover: rgba(255, 255, 255, 0.2);
--color-scrollbar-thumb-light: rgba(0, 0, 0, 0.15);
--color-scrollbar-thumb-light-hover: rgba(0, 0, 0, 0.2);
--color-scrollbar-thumb: var(--color-scrollbar-thumb-dark);
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-dark-hover);
}
body[theme-mode='light'] {
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
--color-scrollbar-thumb: var(--color-scrollbar-thumb-light);
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-light-hover);
}
/* 全局初始化滚动条样式 */
@ -34,3 +39,13 @@ pre:not(.shiki)::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
}
.shiki-dark {
--color-scrollbar-thumb: var(--color-scrollbar-thumb-dark);
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-dark-hover);
}
.shiki-light {
--color-scrollbar-thumb: var(--color-scrollbar-thumb-light);
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-light-hover);
}

View File

@ -1,12 +1,14 @@
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
import { useSettings } from '@renderer/hooks/useSettings'
import { uuid } from '@renderer/utils'
import { getReactStyleFromToken } from '@renderer/utils/shiki'
import { useVirtualizer } from '@tanstack/react-virtual'
import { debounce } from 'lodash'
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core'
import styled from 'styled-components'
interface CodePreviewProps {
@ -15,297 +17,257 @@ interface CodePreviewProps {
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
const MAX_COLLAPSE_HEIGHT = 350
/**
* Shiki
*
* - shiki tokenizer
* -
* - 使
* -
*/
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
const [isInViewport, setIsInViewport] = useState(false)
const codeContainerRef = useRef<HTMLDivElement>(null)
const processingRef = useRef(false)
const latestRequestedContentRef = useRef<string | null>(null)
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
const shikiThemeRef = useRef<HTMLDivElement>(null)
const scrollerRef = useRef<HTMLDivElement>(null)
const callerId = useRef(`${Date.now()}-${uuid()}`).current
const shikiThemeRef = useRef(activeShikiTheme)
const rawLines = useMemo(() => (typeof children === 'string' ? children.trimEnd().split('\n') : []), [children])
const { t } = useTranslation()
const { registerTool, removeTool } = useCodeTool(setTools)
// 展开/折叠工具
useEffect(() => {
registerTool({
...TOOL_SPECS.expand,
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
icon: expandOverride ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
tooltip: expandOverride ? t('code_block.collapse') : t('code_block.expand'),
visible: () => {
const scrollHeight = codeContainerRef.current?.scrollHeight
return codeCollapsible && (scrollHeight ?? 0) > 350
const scrollHeight = scrollerRef.current?.scrollHeight
return codeCollapsible && (scrollHeight ?? 0) > MAX_COLLAPSE_HEIGHT
},
onClick: () => setIsExpanded((prev) => !prev)
onClick: () => setExpandOverride((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.expand.id)
}, [codeCollapsible, isExpanded, registerTool, removeTool, t])
}, [codeCollapsible, expandOverride, registerTool, removeTool, t])
// 自动换行工具
useEffect(() => {
registerTool({
...TOOL_SPECS.wrap,
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
icon: unwrapOverride ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
tooltip: unwrapOverride ? t('code_block.wrap.on') : t('code_block.wrap.off'),
visible: () => codeWrappable,
onClick: () => setIsUnwrapped((prev) => !prev)
onClick: () => setUnwrapOverride((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.wrap.id)
}, [codeWrappable, isUnwrapped, registerTool, removeTool, t])
}, [codeWrappable, unwrapOverride, registerTool, removeTool, t])
// 更新展开状态
// 重置用户操作(可以考虑移除,保持用户操作结果)
useEffect(() => {
setIsExpanded(!codeCollapsible)
setExpandOverride(!codeCollapsible)
}, [codeCollapsible])
// 更新换行状态
// 重置用户操作(可以考虑移除,保持用户操作结果)
useEffect(() => {
setIsUnwrapped(!codeWrappable)
setUnwrapOverride(!codeWrappable)
}, [codeWrappable])
const highlightCode = useCallback(async () => {
const currentContent = typeof children === 'string' ? children.trimEnd() : ''
const shouldCollapse = useMemo(() => codeCollapsible && !expandOverride, [codeCollapsible, expandOverride])
const shouldWrap = useMemo(() => codeWrappable && !unwrapOverride, [codeWrappable, unwrapOverride])
// 记录最新要处理的内容,为了保证最终状态正确
latestRequestedContentRef.current = currentContent
// 如果正在处理,先跳出,等到完成后会检查是否有新内容
if (processingRef.current) return
processingRef.current = true
try {
// 循环处理,确保会处理最新内容
while (latestRequestedContentRef.current !== null) {
const contentToProcess = latestRequestedContentRef.current
latestRequestedContentRef.current = null // 标记开始处理
// 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮
const result = await highlightStreamingCode(contentToProcess, language, callerId)
// 如有结果,更新 tokenLines
if (result.lines.length > 0 || result.recall !== 0) {
setTokenLines((prev) => {
return result.recall === -1
? result.lines
: [...prev.slice(0, Math.max(0, prev.length - result.recall)), ...result.lines]
})
}
}
} finally {
processingRef.current = false
}
}, [highlightStreamingCode, language, callerId, children])
// 主题变化时强制重新高亮
useEffect(() => {
if (shikiThemeRef.current !== activeShikiTheme) {
shikiThemeRef.current = activeShikiTheme
cleanupTokenizers(callerId)
setTokenLines([])
}
}, [activeShikiTheme, callerId, cleanupTokenizers])
// 组件卸载时清理资源
useEffect(() => {
return () => cleanupTokenizers(callerId)
}, [callerId, cleanupTokenizers])
// 视口检测逻辑,进入视口后触发第一次代码高亮
useEffect(() => {
const codeElement = codeContainerRef.current
if (!codeElement) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].intersectionRatio > 0) {
setIsInViewport(true)
observer.disconnect()
}
},
{
rootMargin: '50px 0px 50px 0px'
}
)
observer.observe(codeElement)
return () => observer.disconnect()
}, []) // 只执行一次
// 触发代码高亮
useEffect(() => {
if (!isInViewport) return
setTimeout(highlightCode, 0)
}, [isInViewport, highlightCode])
const lastDigitsRef = useRef(1)
useLayoutEffect(() => {
const container = codeContainerRef.current
if (!container || !codeShowLineNumbers) return
const digits = Math.max(tokenLines.length.toString().length, 1)
if (digits === lastDigitsRef.current) return
const gutterWidth = digits * 0.6
container.style.setProperty('--gutter-width', `${gutterWidth}rem`)
lastDigitsRef.current = digits
}, [codeShowLineNumbers, tokenLines.length])
const hasHighlightedCode = tokenLines.length > 0
return (
<ContentContainer
ref={codeContainerRef}
$wrap={codeWrappable && !isUnwrapped}
$fadeIn={hasHighlightedCode}
style={{
fontSize: fontSize - 1,
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
}}>
{hasHighlightedCode ? (
<ShikiTokensRenderer language={language} tokenLines={tokenLines} showLineNumbers={codeShowLineNumbers} />
) : (
<CodePlaceholder>{children}</CodePlaceholder>
)}
</ContentContainer>
// 计算行号数字位数
const gutterDigits = useMemo(
() => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
[codeShowLineNumbers, rawLines.length]
)
}
interface ShikiTokensRendererProps {
language: string
tokenLines: ThemedToken[][]
showLineNumbers?: boolean
}
/**
* Shiki tokens
*
* 便 virtual list
*/
const ShikiTokensRenderer: React.FC<ShikiTokensRendererProps> = memo(({ language, tokenLines, showLineNumbers }) => {
const { getShikiPreProperties } = useCodeStyle()
const rendererRef = useRef<HTMLPreElement>(null)
// 设置 pre 标签属性
useLayoutEffect(() => {
getShikiPreProperties(language).then((properties) => {
const pre = rendererRef.current
if (pre) {
pre.className = properties.class
pre.style.cssText = properties.style
pre.tabIndex = properties.tabindex
const shikiTheme = shikiThemeRef.current
if (shikiTheme) {
shikiTheme.className = `${properties.class || 'shiki'}`
// 滚动条适应 shiki 主题变化而非应用主题
shikiTheme.classList.add(isShikiThemeDark ? 'shiki-dark' : 'shiki-light')
if (properties.style) {
shikiTheme.style.cssText += `${properties.style}`
}
shikiTheme.tabIndex = properties.tabindex
}
})
}, [language, getShikiPreProperties])
}, [language, getShikiPreProperties, isShikiThemeDark])
// Virtualizer 配置
const getScrollElement = useCallback(() => scrollerRef.current, [])
const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId])
const estimateSize = useCallback(() => (fontSize - 1) * 1.6, [fontSize]) // 同步全局样式
// 创建 virtualizer 实例
const virtualizer = useVirtualizer({
count: rawLines.length,
getScrollElement,
getItemKey,
estimateSize,
overscan: 20
})
const virtualItems = virtualizer.getVirtualItems()
// 使用代码高亮 Hook
const { tokenLines, highlightLines } = useCodeHighlight({
rawLines,
language,
callerId
})
// 防抖高亮提高流式响应的性能,数字大一点也不会影响用户体验
const debouncedHighlightLines = useMemo(() => debounce(highlightLines, 300), [highlightLines])
// 渐进式高亮
useEffect(() => {
if (virtualItems.length > 0 && shikiThemeRef.current) {
const lastIndex = virtualItems[virtualItems.length - 1].index
debouncedHighlightLines(lastIndex + 1)
}
}, [virtualItems, debouncedHighlightLines])
return (
<pre className="shiki" ref={rendererRef}>
<code>
{tokenLines.map((lineTokens, lineIndex) => (
<span key={`line-${lineIndex}`} className="line">
{showLineNumbers && <span className="line-number">{lineIndex + 1}</span>}
<span className="line-content">
{lineTokens.map((token, tokenIndex) => (
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
{token.content}
</span>
))}
</span>
</span>
))}
</code>
</pre>
<div ref={shikiThemeRef}>
<ScrollContainer
ref={scrollerRef}
className="shiki-scroller"
$wrap={shouldWrap}
style={
{
'--gutter-width': `${gutterDigits}ch`,
fontSize: `${fontSize - 1}px`,
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined
} as React.CSSProperties
}>
<div
className="shiki-list"
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
<div
style={{
/*
* FIXME: @tanstack/react-virtual 使
* `self-end`
*
*
*
*/
position: 'relative',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
willChange: 'transform'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement}>
<VirtualizedRow
rawLine={rawLines[virtualItem.index]}
tokenLine={tokenLines[virtualItem.index]}
showLineNumbers={codeShowLineNumbers}
index={virtualItem.index}
/>
</div>
))}
</div>
</div>
</ScrollContainer>
</div>
)
})
const ContentContainer = styled.div<{
$wrap: boolean
$fadeIn: boolean
}>`
position: relative;
overflow: auto;
border-radius: inherit;
margin-top: 0;
/* gutter 宽度默认值 */
--gutter-width: 0.6rem;
.shiki {
padding: 1em;
border-radius: inherit;
code {
display: flex;
flex-direction: column;
.line {
display: flex;
align-items: flex-start;
min-height: 1.3rem;
.line-number {
width: var(--gutter-width);
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')};
}
}
}
}
}
@keyframes contentFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.1s ease-in forwards' : 'none')};
`
const CodePlaceholder = styled.div`
display: block;
opacity: 0.1;
white-space: pre-wrap;
word-break: break-all;
overflow-x: hidden;
min-height: 1.3rem;
`
}
CodePreview.displayName = 'CodePreview'
interface VirtualizedRowData {
rawLine: string
tokenLine?: any[]
showLineNumbers: boolean
}
/**
*
*/
const VirtualizedRow = memo(
({ rawLine, tokenLine, showLineNumbers, index }: VirtualizedRowData & { index: number }) => {
return (
<div className="line">
{showLineNumbers && <span className="line-number">{index + 1}</span>}
<span className="line-content">
{tokenLine ? (
// 渲染高亮后的内容
tokenLine.map((token, tokenIndex) => (
<span key={tokenIndex} style={getReactStyleFromToken(token)}>
{token.content}
</span>
))
) : (
// 渲染原始内容
<span className="line-content-raw">{rawLine || ' '}</span>
)}
</span>
</div>
)
}
)
VirtualizedRow.displayName = 'VirtualizedRow'
const ScrollContainer = styled.div<{
$wrap?: boolean
}>`
display: block;
overflow: auto;
position: relative;
border-radius: inherit;
height: auto;
padding: 0.5em 1em;
.line {
display: flex;
align-items: flex-start;
width: 100%;
.line-number {
width: var(--gutter-width, 1.2ch);
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;
line-height: inherit;
* {
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
}
}
.line-content-raw {
opacity: 0.35;
}
}
`
export default memo(CodePreview)

View File

@ -7,6 +7,7 @@ import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThem
import * as cmThemes from '@uiw/codemirror-themes-all'
import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
import type { BundledThemeInfo } from 'shiki/types'
interface CodeStyleContextType {
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
@ -17,6 +18,7 @@ interface CodeStyleContextType {
shikiMarkdownIt: (code: string) => Promise<string>
themeNames: string[]
activeShikiTheme: string
isShikiThemeDark: boolean
activeCmTheme: any
languageMap: Record<string, string>
}
@ -30,6 +32,7 @@ const defaultCodeStyleContext: CodeStyleContextType = {
shikiMarkdownIt: async () => '',
themeNames: ['auto'],
activeShikiTheme: 'auto',
isShikiThemeDark: false,
activeCmTheme: null,
languageMap: {}
}
@ -39,13 +42,13 @@ const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleCon
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { codeEditor, codePreview } = useSettings()
const { theme } = useTheme()
const [shikiThemes, setShikiThemes] = useState({})
const [shikiThemesInfo, setShikiThemesInfo] = useState<BundledThemeInfo[]>([])
useMermaid()
useEffect(() => {
if (!codeEditor.enabled) {
getShiki().then(({ bundledThemes }) => {
setShikiThemes(bundledThemes)
getShiki().then(({ bundledThemesInfo }) => {
setShikiThemesInfo(bundledThemesInfo)
})
}
}, [codeEditor.enabled])
@ -61,9 +64,9 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
}
// Shiki 主题
return ['auto', ...Object.keys(shikiThemes)]
}, [codeEditor.enabled, shikiThemes])
// Shiki 主题,取出所有 BundledThemeInfo 的 id 作为主题名
return ['auto', ...shikiThemesInfo.map((info) => info.id)]
}, [codeEditor.enabled, shikiThemesInfo])
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
const activeShikiTheme = useMemo(() => {
@ -75,6 +78,11 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
return codeStyle
}, [theme, codePreview, themeNames])
const isShikiThemeDark = useMemo(() => {
const themeInfo = shikiThemesInfo.find((info) => info.id === activeShikiTheme)
return themeInfo?.type === 'dark'
}, [activeShikiTheme, shikiThemesInfo])
// 获取当前使用的 CodeMirror 主题对象(只用于编辑器)
const activeCmTheme = useMemo(() => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
@ -166,6 +174,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
shikiMarkdownIt,
themeNames,
activeShikiTheme,
isShikiThemeDark,
activeCmTheme,
languageMap
}),
@ -178,6 +187,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
shikiMarkdownIt,
themeNames,
activeShikiTheme,
isShikiThemeDark,
activeCmTheme,
languageMap
]

View File

@ -0,0 +1,99 @@
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ThemedToken } from 'shiki/core'
interface UseCodeHighlightOptions {
rawLines: string[]
language: string
callerId: string
}
interface UseCodeHighlightReturn {
tokenLines: ThemedToken[][]
highlightLines: (count?: number) => Promise<void>
resetHighlight: () => void
}
/**
* shiki
*/
export const useCodeHighlight = ({ rawLines, language, callerId }: UseCodeHighlightOptions): UseCodeHighlightReturn => {
const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
const processingRef = useRef(false)
const latestRequestedContentRef = useRef<string | null>(null)
const tokenLinesCountRef = useRef(0)
const shikiThemeRef = useRef(activeShikiTheme)
useEffect(() => {
tokenLinesCountRef.current = tokenLines.length
}, [tokenLines])
const highlightLines = useCallback(
async (count?: number) => {
const targetCount = count === undefined ? rawLines.length : Math.min(count, rawLines.length)
// 数量相等也可能内容不同,交给 ShikiStreamService 处理
if (targetCount < tokenLinesCountRef.current) return
const currentContent = rawLines.slice(0, targetCount).join('\n').trimEnd()
// 记录最新要处理的内容,为了保证最终状态正确
latestRequestedContentRef.current = currentContent
// 如果正在处理,先跳出,等到完成后会检查是否有新内容
if (processingRef.current) return
processingRef.current = true
try {
// 循环处理,确保会处理最新内容
while (latestRequestedContentRef.current !== null) {
const contentToProcess = latestRequestedContentRef.current
latestRequestedContentRef.current = null // 标记开始处理
// 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮
const result = await highlightStreamingCode(contentToProcess, language, callerId)
// 如有结果,更新 tokenLines
if (result.lines.length > 0 || result.recall !== 0) {
setTokenLines((prev) => {
return result.recall === -1
? result.lines
: [...prev.slice(0, Math.max(0, prev.length - result.recall)), ...result.lines]
})
}
}
} finally {
processingRef.current = false
}
},
[rawLines, highlightStreamingCode, language, callerId]
)
const resetHighlight = useCallback(() => {
cleanupTokenizers(callerId)
setTokenLines([])
}, [callerId, cleanupTokenizers])
// 主题变化时强制重新高亮
useEffect(() => {
if (shikiThemeRef.current !== activeShikiTheme) {
shikiThemeRef.current = activeShikiTheme
resetHighlight()
}
}, [activeShikiTheme, resetHighlight])
// 组件卸载时清理资源
useEffect(() => {
return () => {
cleanupTokenizers(callerId)
}
}, [callerId, cleanupTokenizers])
return {
tokenLines,
highlightLines,
resetHighlight
}
}

View File

@ -4178,6 +4178,25 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/react-virtual@npm:^3.13.12":
version: 3.13.12
resolution: "@tanstack/react-virtual@npm:3.13.12"
dependencies:
"@tanstack/virtual-core": "npm:3.13.12"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/0eda3d5691ec3bf93a1cdaa955f4972c7aa9a5026179622824bb52ff8c47e59ee4634208e52d77f43ffb3ce435ee39a0899d6a81f6316918ce89d68122490371
languageName: node
linkType: hard
"@tanstack/virtual-core@npm:3.13.12":
version: 3.13.12
resolution: "@tanstack/virtual-core@npm:3.13.12"
checksum: 10c0/483f38761b73db05c181c10181f0781c1051be3350ae5c378e65057e5f1fdd6606e06e17dbaad8a5e36c04b208ea1a1344cacd4eca0dcde60f335cf398e4d698
languageName: node
linkType: hard
"@testing-library/dom@npm:^10.4.0":
version: 10.4.0
resolution: "@testing-library/dom@npm:10.4.0"
@ -5832,6 +5851,7 @@ __metadata:
"@strongtz/win32-arm64-msvc": "npm:^0.4.7"
"@swc/plugin-styled-components": "npm:^7.1.5"
"@tanstack/react-query": "npm:^5.27.0"
"@tanstack/react-virtual": "npm:^3.13.12"
"@testing-library/dom": "npm:^10.4.0"
"@testing-library/jest-dom": "npm:^6.6.3"
"@testing-library/react": "npm:^16.3.0"