mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 23:10:20 +08:00
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:
parent
e3057f90ea
commit
d7f2ebcb6e
@ -112,6 +112,7 @@
|
|||||||
"@shikijs/markdown-it": "^3.7.0",
|
"@shikijs/markdown-it": "^3.7.0",
|
||||||
"@swc/plugin-styled-components": "^7.1.5",
|
"@swc/plugin-styled-components": "^7.1.5",
|
||||||
"@tanstack/react-query": "^5.27.0",
|
"@tanstack/react-query": "^5.27.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
|||||||
@ -323,6 +323,11 @@ mjx-container {
|
|||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shiki 相关样式 */
|
||||||
|
.shiki {
|
||||||
|
font-family: var(--code-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
/* CodeMirror 相关样式 */
|
/* CodeMirror 相关样式 */
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
:root {
|
:root {
|
||||||
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
|
--color-scrollbar-thumb-dark: rgba(255, 255, 255, 0.15);
|
||||||
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
|
--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'] {
|
body[theme-mode='light'] {
|
||||||
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
--color-scrollbar-thumb: var(--color-scrollbar-thumb-light);
|
||||||
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
|
--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);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
|
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
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 { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import { debounce } from 'lodash'
|
||||||
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, useLayoutEffect, useRef, useState } from 'react'
|
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ThemedToken } from 'shiki/core'
|
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface CodePreviewProps {
|
interface CodePreviewProps {
|
||||||
@ -15,297 +17,257 @@ interface CodePreviewProps {
|
|||||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_COLLAPSE_HEIGHT = 350
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shiki 流式代码高亮组件
|
* Shiki 流式代码高亮组件
|
||||||
*
|
|
||||||
* - 通过 shiki tokenizer 处理流式响应,高性能
|
* - 通过 shiki tokenizer 处理流式响应,高性能
|
||||||
* - 进入视口后触发高亮,改善页面内有大量长代码块时的响应
|
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||||
* - 并发安全
|
* - 并发安全
|
||||||
*/
|
*/
|
||||||
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||||
const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
|
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
|
||||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
|
||||||
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
|
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||||
const [isInViewport, setIsInViewport] = useState(false)
|
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||||
const codeContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const processingRef = useRef(false)
|
|
||||||
const latestRequestedContentRef = useRef<string | null>(null)
|
|
||||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
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 { t } = useTranslation()
|
||||||
|
|
||||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||||
|
|
||||||
// 展开/折叠工具
|
// 展开/折叠工具
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerTool({
|
registerTool({
|
||||||
...TOOL_SPECS.expand,
|
...TOOL_SPECS.expand,
|
||||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
icon: expandOverride ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
tooltip: expandOverride ? t('code_block.collapse') : t('code_block.expand'),
|
||||||
visible: () => {
|
visible: () => {
|
||||||
const scrollHeight = codeContainerRef.current?.scrollHeight
|
const scrollHeight = scrollerRef.current?.scrollHeight
|
||||||
return codeCollapsible && (scrollHeight ?? 0) > 350
|
return codeCollapsible && (scrollHeight ?? 0) > MAX_COLLAPSE_HEIGHT
|
||||||
},
|
},
|
||||||
onClick: () => setIsExpanded((prev) => !prev)
|
onClick: () => setExpandOverride((prev) => !prev)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => removeTool(TOOL_SPECS.expand.id)
|
return () => removeTool(TOOL_SPECS.expand.id)
|
||||||
}, [codeCollapsible, isExpanded, registerTool, removeTool, t])
|
}, [codeCollapsible, expandOverride, registerTool, removeTool, t])
|
||||||
|
|
||||||
// 自动换行工具
|
// 自动换行工具
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerTool({
|
registerTool({
|
||||||
...TOOL_SPECS.wrap,
|
...TOOL_SPECS.wrap,
|
||||||
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
icon: unwrapOverride ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||||
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
tooltip: unwrapOverride ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||||
visible: () => codeWrappable,
|
visible: () => codeWrappable,
|
||||||
onClick: () => setIsUnwrapped((prev) => !prev)
|
onClick: () => setUnwrapOverride((prev) => !prev)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||||
}, [codeWrappable, isUnwrapped, registerTool, removeTool, t])
|
}, [codeWrappable, unwrapOverride, registerTool, removeTool, t])
|
||||||
|
|
||||||
// 更新展开状态
|
// 重置用户操作(可以考虑移除,保持用户操作结果)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsExpanded(!codeCollapsible)
|
setExpandOverride(!codeCollapsible)
|
||||||
}, [codeCollapsible])
|
}, [codeCollapsible])
|
||||||
|
|
||||||
// 更新换行状态
|
// 重置用户操作(可以考虑移除,保持用户操作结果)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsUnwrapped(!codeWrappable)
|
setUnwrapOverride(!codeWrappable)
|
||||||
}, [codeWrappable])
|
}, [codeWrappable])
|
||||||
|
|
||||||
const highlightCode = useCallback(async () => {
|
const shouldCollapse = useMemo(() => codeCollapsible && !expandOverride, [codeCollapsible, expandOverride])
|
||||||
const currentContent = typeof children === 'string' ? children.trimEnd() : ''
|
const shouldWrap = useMemo(() => codeWrappable && !unwrapOverride, [codeWrappable, unwrapOverride])
|
||||||
|
|
||||||
// 记录最新要处理的内容,为了保证最终状态正确
|
// 计算行号数字位数
|
||||||
latestRequestedContentRef.current = currentContent
|
const gutterDigits = useMemo(
|
||||||
|
() => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
||||||
// 如果正在处理,先跳出,等到完成后会检查是否有新内容
|
[codeShowLineNumbers, rawLines.length]
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
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 标签属性
|
// 设置 pre 标签属性
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
getShikiPreProperties(language).then((properties) => {
|
getShikiPreProperties(language).then((properties) => {
|
||||||
const pre = rendererRef.current
|
const shikiTheme = shikiThemeRef.current
|
||||||
if (pre) {
|
if (shikiTheme) {
|
||||||
pre.className = properties.class
|
shikiTheme.className = `${properties.class || 'shiki'}`
|
||||||
pre.style.cssText = properties.style
|
// 滚动条适应 shiki 主题变化而非应用主题
|
||||||
pre.tabIndex = properties.tabindex
|
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 (
|
return (
|
||||||
<pre className="shiki" ref={rendererRef}>
|
<div ref={shikiThemeRef}>
|
||||||
<code>
|
<ScrollContainer
|
||||||
{tokenLines.map((lineTokens, lineIndex) => (
|
ref={scrollerRef}
|
||||||
<span key={`line-${lineIndex}`} className="line">
|
className="shiki-scroller"
|
||||||
{showLineNumbers && <span className="line-number">{lineIndex + 1}</span>}
|
$wrap={shouldWrap}
|
||||||
<span className="line-content">
|
style={
|
||||||
{lineTokens.map((token, tokenIndex) => (
|
{
|
||||||
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
|
'--gutter-width': `${gutterDigits}ch`,
|
||||||
{token.content}
|
fontSize: `${fontSize - 1}px`,
|
||||||
</span>
|
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined
|
||||||
))}
|
} as React.CSSProperties
|
||||||
</span>
|
}>
|
||||||
</span>
|
<div
|
||||||
))}
|
className="shiki-list"
|
||||||
</code>
|
style={{
|
||||||
</pre>
|
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'
|
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)
|
export default memo(CodePreview)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThem
|
|||||||
import * as cmThemes from '@uiw/codemirror-themes-all'
|
import * as cmThemes from '@uiw/codemirror-themes-all'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
|
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { BundledThemeInfo } from 'shiki/types'
|
||||||
|
|
||||||
interface CodeStyleContextType {
|
interface CodeStyleContextType {
|
||||||
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||||
@ -17,6 +18,7 @@ interface CodeStyleContextType {
|
|||||||
shikiMarkdownIt: (code: string) => Promise<string>
|
shikiMarkdownIt: (code: string) => Promise<string>
|
||||||
themeNames: string[]
|
themeNames: string[]
|
||||||
activeShikiTheme: string
|
activeShikiTheme: string
|
||||||
|
isShikiThemeDark: boolean
|
||||||
activeCmTheme: any
|
activeCmTheme: any
|
||||||
languageMap: Record<string, string>
|
languageMap: Record<string, string>
|
||||||
}
|
}
|
||||||
@ -30,6 +32,7 @@ const defaultCodeStyleContext: CodeStyleContextType = {
|
|||||||
shikiMarkdownIt: async () => '',
|
shikiMarkdownIt: async () => '',
|
||||||
themeNames: ['auto'],
|
themeNames: ['auto'],
|
||||||
activeShikiTheme: 'auto',
|
activeShikiTheme: 'auto',
|
||||||
|
isShikiThemeDark: false,
|
||||||
activeCmTheme: null,
|
activeCmTheme: null,
|
||||||
languageMap: {}
|
languageMap: {}
|
||||||
}
|
}
|
||||||
@ -39,13 +42,13 @@ const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleCon
|
|||||||
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const { codeEditor, codePreview } = useSettings()
|
const { codeEditor, codePreview } = useSettings()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [shikiThemes, setShikiThemes] = useState({})
|
const [shikiThemesInfo, setShikiThemesInfo] = useState<BundledThemeInfo[]>([])
|
||||||
useMermaid()
|
useMermaid()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!codeEditor.enabled) {
|
if (!codeEditor.enabled) {
|
||||||
getShiki().then(({ bundledThemes }) => {
|
getShiki().then(({ bundledThemesInfo }) => {
|
||||||
setShikiThemes(bundledThemes)
|
setShikiThemesInfo(bundledThemesInfo)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [codeEditor.enabled])
|
}, [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))
|
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shiki 主题
|
// Shiki 主题,取出所有 BundledThemeInfo 的 id 作为主题名
|
||||||
return ['auto', ...Object.keys(shikiThemes)]
|
return ['auto', ...shikiThemesInfo.map((info) => info.id)]
|
||||||
}, [codeEditor.enabled, shikiThemes])
|
}, [codeEditor.enabled, shikiThemesInfo])
|
||||||
|
|
||||||
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
|
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
|
||||||
const activeShikiTheme = useMemo(() => {
|
const activeShikiTheme = useMemo(() => {
|
||||||
@ -75,6 +78,11 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
|||||||
return codeStyle
|
return codeStyle
|
||||||
}, [theme, codePreview, themeNames])
|
}, [theme, codePreview, themeNames])
|
||||||
|
|
||||||
|
const isShikiThemeDark = useMemo(() => {
|
||||||
|
const themeInfo = shikiThemesInfo.find((info) => info.id === activeShikiTheme)
|
||||||
|
return themeInfo?.type === 'dark'
|
||||||
|
}, [activeShikiTheme, shikiThemesInfo])
|
||||||
|
|
||||||
// 获取当前使用的 CodeMirror 主题对象(只用于编辑器)
|
// 获取当前使用的 CodeMirror 主题对象(只用于编辑器)
|
||||||
const activeCmTheme = useMemo(() => {
|
const activeCmTheme = useMemo(() => {
|
||||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||||
@ -166,6 +174,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
|||||||
shikiMarkdownIt,
|
shikiMarkdownIt,
|
||||||
themeNames,
|
themeNames,
|
||||||
activeShikiTheme,
|
activeShikiTheme,
|
||||||
|
isShikiThemeDark,
|
||||||
activeCmTheme,
|
activeCmTheme,
|
||||||
languageMap
|
languageMap
|
||||||
}),
|
}),
|
||||||
@ -178,6 +187,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
|||||||
shikiMarkdownIt,
|
shikiMarkdownIt,
|
||||||
themeNames,
|
themeNames,
|
||||||
activeShikiTheme,
|
activeShikiTheme,
|
||||||
|
isShikiThemeDark,
|
||||||
activeCmTheme,
|
activeCmTheme,
|
||||||
languageMap
|
languageMap
|
||||||
]
|
]
|
||||||
|
|||||||
99
src/renderer/src/hooks/useCodeHighlight.ts
Normal file
99
src/renderer/src/hooks/useCodeHighlight.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
yarn.lock
20
yarn.lock
@ -4178,6 +4178,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@testing-library/dom@npm:^10.4.0":
|
||||||
version: 10.4.0
|
version: 10.4.0
|
||||||
resolution: "@testing-library/dom@npm:10.4.0"
|
resolution: "@testing-library/dom@npm:10.4.0"
|
||||||
@ -5832,6 +5851,7 @@ __metadata:
|
|||||||
"@strongtz/win32-arm64-msvc": "npm:^0.4.7"
|
"@strongtz/win32-arm64-msvc": "npm:^0.4.7"
|
||||||
"@swc/plugin-styled-components": "npm:^7.1.5"
|
"@swc/plugin-styled-components": "npm:^7.1.5"
|
||||||
"@tanstack/react-query": "npm:^5.27.0"
|
"@tanstack/react-query": "npm:^5.27.0"
|
||||||
|
"@tanstack/react-virtual": "npm:^3.13.12"
|
||||||
"@testing-library/dom": "npm:^10.4.0"
|
"@testing-library/dom": "npm:^10.4.0"
|
||||||
"@testing-library/jest-dom": "npm:^6.6.3"
|
"@testing-library/jest-dom": "npm:^6.6.3"
|
||||||
"@testing-library/react": "npm:^16.3.0"
|
"@testing-library/react": "npm:^16.3.0"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user