fix: improve code block copy in collapsed state with virtual scroll

- Add saveSelection mechanism to track selection across virtual scroll updates
- Implement custom copy handler to extract complete content from raw data
- Auto-expand code block when multi-line selection is detected in collapsed state
- Only enable auto-expand when codeCollapsible setting is enabled
- Add comprehensive logging for debugging selection and copy issues

Fixes issue where copying code in collapsed state would lose content from
virtualized rows that are not rendered in DOM. The solution captures
selection position (line + offset) during scroll and uses it to extract
complete content from the original source when copying.
This commit is contained in:
zhaokun 2025-11-26 17:29:17 +08:00
parent dc8df98929
commit 37c2b5ecbe
2 changed files with 250 additions and 2 deletions

View File

@ -264,9 +264,10 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
expanded={shouldExpand} expanded={shouldExpand}
wrapped={shouldWrap} wrapped={shouldWrap}
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`} maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
onRequestExpand={codeCollapsible ? () => setExpandOverride(true) : undefined}
/> />
), ),
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap] [children, codeCollapsible, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap]
) )
// 特殊视图组件映射 // 特殊视图组件映射

View File

@ -1,3 +1,4 @@
import { loggerService } from '@logger'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight' import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
@ -9,6 +10,15 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef }
import type { ThemedToken } from 'shiki/core' import type { ThemedToken } from 'shiki/core'
import styled from 'styled-components' import styled from 'styled-components'
const logger = loggerService.withContext('CodeViewer')
interface SavedSelection {
startLine: number
startOffset: number
endLine: number
endOffset: number
}
interface CodeViewerProps { interface CodeViewerProps {
/** Code string value. */ /** Code string value. */
value: string value: string
@ -52,6 +62,10 @@ interface CodeViewerProps {
* @default true * @default true
*/ */
wrapped?: boolean wrapped?: boolean
/**
* Callback to request expansion when multi-line selection is detected.
*/
onRequestExpand?: () => void
} }
/** /**
@ -70,13 +84,16 @@ const CodeViewer = ({
fontSize: customFontSize, fontSize: customFontSize,
className, className,
expanded = true, expanded = true,
wrapped = true wrapped = true,
onRequestExpand
}: CodeViewerProps) => { }: CodeViewerProps) => {
const { codeShowLineNumbers: _lineNumbers, fontSize: _fontSize } = useSettings() const { codeShowLineNumbers: _lineNumbers, fontSize: _fontSize } = useSettings()
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
const shikiThemeRef = useRef<HTMLDivElement>(null) const shikiThemeRef = useRef<HTMLDivElement>(null)
const scrollerRef = useRef<HTMLDivElement>(null) const scrollerRef = useRef<HTMLDivElement>(null)
const callerId = useRef(`${Date.now()}-${uuid()}`).current const callerId = useRef(`${Date.now()}-${uuid()}`).current
const savedSelectionRef = useRef<SavedSelection | null>(null)
const isRestoringSelectionRef = useRef(false)
const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize]) const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize])
const lineNumbers = useMemo(() => options?.lineNumbers ?? _lineNumbers, [options?.lineNumbers, _lineNumbers]) const lineNumbers = useMemo(() => options?.lineNumbers ?? _lineNumbers, [options?.lineNumbers, _lineNumbers])
@ -112,6 +129,194 @@ const CodeViewer = ({
} }
}, [language, getShikiPreProperties, isShikiThemeDark, className]) }, [language, getShikiPreProperties, isShikiThemeDark, className])
// 保存当前选区的逻辑位置
const saveSelection = useCallback((): SavedSelection | null => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return null
}
const range = selection.getRangeAt(0)
const scroller = scrollerRef.current
if (!scroller) return null
// 查找选区起始和结束位置对应的行号
const findLineAndOffset = (node: Node, offset: number): { line: number; offset: number } | null => {
// 向上查找包含 data-index 属性的元素
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement
// 跳过行号元素,找到实际的行内容
while (element) {
if (element.classList?.contains('line-number')) {
// 如果在行号上,移动到同级的 line-content
const lineContainer = element.parentElement
const lineContent = lineContainer?.querySelector('.line-content')
if (lineContent) {
element = lineContent as Element
break
}
}
if (element.hasAttribute('data-index')) {
break
}
element = element.parentElement
}
if (!element || !element.hasAttribute('data-index')) {
logger.warn('Could not find data-index element', {
nodeName: node.nodeName,
nodeType: node.nodeType
})
return null
}
const lineIndex = parseInt(element.getAttribute('data-index') || '0', 10)
const lineContent = element.querySelector('.line-content') || element
// 计算在该行内的字符偏移量
let charOffset = 0
if (node.nodeType === Node.TEXT_NODE) {
// 遍历该行的所有文本节点,找到当前节点的位置
const walker = document.createTreeWalker(lineContent as Node, NodeFilter.SHOW_TEXT)
let currentNode: Node | null
while ((currentNode = walker.nextNode())) {
if (currentNode === node) {
charOffset += offset
break
}
charOffset += currentNode.textContent?.length || 0
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
// 如果是元素节点,计算之前所有文本的长度
const textBefore = (node as Element).textContent?.slice(0, offset) || ''
charOffset = textBefore.length
}
logger.info('findLineAndOffset result', {
lineIndex,
charOffset
})
return { line: lineIndex, offset: charOffset }
}
const start = findLineAndOffset(range.startContainer, range.startOffset)
const end = findLineAndOffset(range.endContainer, range.endOffset)
if (!start || !end) {
logger.warn('saveSelection failed', {
hasStart: !!start,
hasEnd: !!end
})
return null
}
logger.info('saveSelection success', {
startLine: start.line,
startOffset: start.offset,
endLine: end.line,
endOffset: end.offset
})
return {
startLine: start.line,
startOffset: start.offset,
endLine: end.line,
endOffset: end.offset
}
}, [])
// 滚动事件处理:保存选择用于复制,但不恢复(避免选择高亮问题)
const handleScroll = useCallback(() => {
if (isRestoringSelectionRef.current) return
// 只保存选择状态用于复制,不在滚动时恢复选择
const saved = saveSelection()
if (saved) {
savedSelectionRef.current = saved
logger.info('Selection saved for copy', {
startLine: saved.startLine,
endLine: saved.endLine
})
}
}, [saveSelection])
// 处理复制事件,确保跨虚拟滚动的复制能获取完整内容
const handleCopy = useCallback(
(event: ClipboardEvent) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return
}
// 优先使用滚动时保存的选择状态,如果没有则实时获取
let saved = savedSelectionRef.current
if (!saved) {
saved = saveSelection()
}
if (!saved) {
logger.warn('Cannot get selection, using browser default')
return
}
const { startLine, startOffset, endLine, endOffset } = saved
// 在折叠状态或跨行选择时,使用自定义复制
const isMultiLine = endLine > startLine
const needsCustomCopy = !expanded || isMultiLine
logger.info('Copy event', {
startLine,
endLine,
startOffset,
endOffset,
expanded,
needsCustomCopy,
usedSavedSelection: !!savedSelectionRef.current
})
if (needsCustomCopy) {
try {
const selectedLines: string[] = []
for (let i = startLine; i <= endLine; i++) {
const line = rawLines[i] || ''
if (i === startLine && i === endLine) {
// 单行选择
selectedLines.push(line.slice(startOffset, endOffset))
} else if (i === startLine) {
// 第一行,从 startOffset 到行尾
selectedLines.push(line.slice(startOffset))
} else if (i === endLine) {
// 最后一行,从行首到 endOffset
selectedLines.push(line.slice(0, endOffset))
} else {
// 中间的完整行
selectedLines.push(line)
}
}
const fullText = selectedLines.join('\n')
logger.info('Custom copy success', {
linesCount: selectedLines.length,
totalLength: fullText.length,
firstLine: selectedLines[0]?.slice(0, 30),
lastLine: selectedLines[selectedLines.length - 1]?.slice(0, 30)
})
event.clipboardData?.setData('text/plain', fullText)
event.preventDefault()
} catch (error) {
logger.error('Custom copy failed', { error })
}
}
},
[saveSelection, rawLines, expanded]
)
// Virtualizer 配置 // Virtualizer 配置
const getScrollElement = useCallback(() => scrollerRef.current, []) const getScrollElement = useCallback(() => scrollerRef.current, [])
const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId]) const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId])
@ -147,6 +352,47 @@ const CodeViewer = ({
} }
}, [virtualItems, debouncedHighlightLines]) }, [virtualItems, debouncedHighlightLines])
// 监听选择变化,清除过期的选择状态,并在折叠状态下自动展开
useEffect(() => {
const handleSelectionChange = () => {
const selection = window.getSelection()
// 如果没有选择或选择已清空,清除保存的状态
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
savedSelectionRef.current = null
return
}
// 如果在折叠状态下检测到跨行选择,自动展开
if (!expanded && onRequestExpand) {
const saved = saveSelection()
if (saved && saved.endLine > saved.startLine) {
logger.info('Multi-line selection detected in collapsed state, requesting expand', {
startLine: saved.startLine,
endLine: saved.endLine
})
onRequestExpand()
}
}
}
document.addEventListener('selectionchange', handleSelectionChange)
return () => {
document.removeEventListener('selectionchange', handleSelectionChange)
}
}, [expanded, onRequestExpand, saveSelection])
// 监听 copy 事件
useEffect(() => {
const scroller = scrollerRef.current
if (!scroller) return
scroller.addEventListener('copy', handleCopy as EventListener)
return () => {
scroller.removeEventListener('copy', handleCopy as EventListener)
}
}, [handleCopy])
// Report scrollHeight when it might change // Report scrollHeight when it might change
useLayoutEffect(() => { useLayoutEffect(() => {
onHeightChange?.(scrollerRef.current?.scrollHeight ?? 0) onHeightChange?.(scrollerRef.current?.scrollHeight ?? 0)
@ -160,6 +406,7 @@ const CodeViewer = ({
$wrap={wrapped} $wrap={wrapped}
$expand={expanded} $expand={expanded}
$lineHeight={estimateSize()} $lineHeight={estimateSize()}
onScroll={handleScroll}
style={ style={
{ {
'--gutter-width': `${gutterDigits}ch`, '--gutter-width': `${gutterDigits}ch`,