diff --git a/package.json b/package.json index f5a42a1349..d5cbc6e707 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index eea9070cae..2cb60e6bb4 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -323,6 +323,11 @@ mjx-container { margin-top: -2px; } +/* Shiki 相关样式 */ +.shiki { + font-family: var(--code-font-family); +} + /* CodeMirror 相关样式 */ .cm-editor { border-radius: inherit; diff --git a/src/renderer/src/assets/styles/scrollbar.scss b/src/renderer/src/assets/styles/scrollbar.scss index 818c082b7e..c5df842f78 100644 --- a/src/renderer/src/assets/styles/scrollbar.scss +++ b/src/renderer/src/assets/styles/scrollbar.scss @@ -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); +} diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 55a10d5535..106d03d21f 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -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) => 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([]) - const [isInViewport, setIsInViewport] = useState(false) - const codeContainerRef = useRef(null) - const processingRef = useRef(false) - const latestRequestedContentRef = useRef(null) + const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() + const [expandOverride, setExpandOverride] = useState(!codeCollapsible) + const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable) + const shikiThemeRef = useRef(null) + const scrollerRef = useRef(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 ? : , - tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'), + icon: expandOverride ? : , + 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 ? : , - tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'), + icon: unwrapOverride ? : , + 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 ( - - {hasHighlightedCode ? ( - - ) : ( - {children} - )} - + // 计算行号数字位数 + 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 = memo(({ language, tokenLines, showLineNumbers }) => { - const { getShikiPreProperties } = useCodeStyle() - const rendererRef = useRef(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 ( -
-      
-        {tokenLines.map((lineTokens, lineIndex) => (
-          
-            {showLineNumbers && {lineIndex + 1}}
-            
-              {lineTokens.map((token, tokenIndex) => (
-                
-                  {token.content}
-                
-              ))}
-            
-          
-        ))}
-      
-    
+
+ +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ +
+ ))} +
+
+
+
) -}) - -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 ( +
+ {showLineNumbers && {index + 1}} + + {tokenLine ? ( + // 渲染高亮后的内容 + tokenLine.map((token, tokenIndex) => ( + + {token.content} + + )) + ) : ( + // 渲染原始内容 + {rawLine || ' '} + )} + +
+ ) + } +) + +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) diff --git a/src/renderer/src/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx index 28b71eb7e1..e702d4847d 100644 --- a/src/renderer/src/context/CodeStyleProvider.tsx +++ b/src/renderer/src/context/CodeStyleProvider.tsx @@ -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 @@ -17,6 +18,7 @@ interface CodeStyleContextType { shikiMarkdownIt: (code: string) => Promise themeNames: string[] activeShikiTheme: string + isShikiThemeDark: boolean activeCmTheme: any languageMap: Record } @@ -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(defaultCodeStyleCon export const CodeStyleProvider: React.FC = ({ children }) => { const { codeEditor, codePreview } = useSettings() const { theme } = useTheme() - const [shikiThemes, setShikiThemes] = useState({}) + const [shikiThemesInfo, setShikiThemesInfo] = useState([]) 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 = ({ 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 = ({ 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 = ({ children }) => shikiMarkdownIt, themeNames, activeShikiTheme, + isShikiThemeDark, activeCmTheme, languageMap }), @@ -178,6 +187,7 @@ export const CodeStyleProvider: React.FC = ({ children }) => shikiMarkdownIt, themeNames, activeShikiTheme, + isShikiThemeDark, activeCmTheme, languageMap ] diff --git a/src/renderer/src/hooks/useCodeHighlight.ts b/src/renderer/src/hooks/useCodeHighlight.ts new file mode 100644 index 0000000000..4ae5e9e97e --- /dev/null +++ b/src/renderer/src/hooks/useCodeHighlight.ts @@ -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 + resetHighlight: () => void +} + +/** + * 用于 shiki 流式代码高亮 + */ +export const useCodeHighlight = ({ rawLines, language, callerId }: UseCodeHighlightOptions): UseCodeHighlightReturn => { + const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle() + const [tokenLines, setTokenLines] = useState([]) + const processingRef = useRef(false) + const latestRequestedContentRef = useRef(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 + } +} diff --git a/yarn.lock b/yarn.lock index 0be9198a6d..0711dc386e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"