mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
fix: Selected area in code block changes after scrolling (#11469)
* 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.
* fix(CodeViewer): scope selection and copy to viewer container to prevent multiple blocks appearing selected\n\n- Add selectionBelongsToViewer() to ensure selection anchors are within this viewer\n- Guard saveSelection, copy handler, and selectionchange auto-expand logic\n- Avoids cross-viewer selection bleed when multiple CodeViewer instances exist on a page\n\nFollow-up to 37c2b5ecb (virtual scroll selection/copy).
* fix(CodeViewer): clear saved selection when active selection belongs to another viewer\n\n- Early-return in selectionchange handler when selection is outside this viewer\n- Complements scoping guards to avoid misleading multi-selection states
* fix(CodeViewer): change logger info to debug for selection and copy events
- Adjust logging level from info to debug for various selection and copy operations to reduce log verbosity.
- Ensure selection belongs to the current viewer before processing.
* fix(CodeViewer): remove invisible character from import statement
* fix(CodeViewer): complete useCallback deps to avoid stale closure
- saveSelection deps -> [selectionBelongsToViewer]
- handleCopy deps -> [selectionBelongsToViewer, expanded, saveSelection, rawLines]
- no behavior change; satisfy exhaustive-deps; reduce risk of stale refs
* fix(CodeViewer): improve selection handling for virtual scrolling and enhance comments
* fix(CodeViewer): handle clipboardData unavailability and remove unused ref
- Add null check for event.clipboardData to prevent silent copy failure
- When clipboardData is unavailable, fall back to browser default copy behavior
- Remove unused isRestoringSelectionRef and its dead code check
- Improve copy reliability in edge cases where clipboard API may be unavailable
This commit is contained in:
parent
fb45d94efb
commit
77fd90ef7d
@ -264,9 +264,10 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
expanded={shouldExpand}
|
||||
wrapped={shouldWrap}
|
||||
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]
|
||||
)
|
||||
|
||||
// 特殊视图组件映射
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
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 styled from 'styled-components'
|
||||
|
||||
const logger = loggerService.withContext('CodeViewer')
|
||||
|
||||
interface SavedSelection {
|
||||
startLine: number
|
||||
startOffset: number
|
||||
endLine: number
|
||||
endOffset: number
|
||||
}
|
||||
|
||||
interface CodeViewerProps {
|
||||
/** Code string value. */
|
||||
value: string
|
||||
@ -52,6 +62,10 @@ interface CodeViewerProps {
|
||||
* @default true
|
||||
*/
|
||||
wrapped?: boolean
|
||||
/**
|
||||
* Callback to request expansion when multi-line selection is detected.
|
||||
*/
|
||||
onRequestExpand?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@ -70,13 +84,24 @@ const CodeViewer = ({
|
||||
fontSize: customFontSize,
|
||||
className,
|
||||
expanded = true,
|
||||
wrapped = true
|
||||
wrapped = true,
|
||||
onRequestExpand
|
||||
}: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers: _lineNumbers, fontSize: _fontSize } = useSettings()
|
||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
const savedSelectionRef = useRef<SavedSelection | null>(null)
|
||||
// Ensure the active selection actually belongs to this CodeViewer instance
|
||||
const selectionBelongsToViewer = useCallback((sel: Selection | null) => {
|
||||
const scroller = scrollerRef.current
|
||||
if (!scroller || !sel || sel.rangeCount === 0) return false
|
||||
|
||||
// Check if selection intersects with scroller
|
||||
const range = sel.getRangeAt(0)
|
||||
return scroller.contains(range.commonAncestorContainer)
|
||||
}, [])
|
||||
|
||||
const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize])
|
||||
const lineNumbers = useMemo(() => options?.lineNumbers ?? _lineNumbers, [options?.lineNumbers, _lineNumbers])
|
||||
@ -112,6 +137,204 @@ const CodeViewer = ({
|
||||
}
|
||||
}, [language, getShikiPreProperties, isShikiThemeDark, className])
|
||||
|
||||
// 保存当前选区的逻辑位置
|
||||
const saveSelection = useCallback((): SavedSelection | null => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Only capture selections within this viewer's scroller
|
||||
if (!selectionBelongsToViewer(selection)) {
|
||||
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
|
||||
|
||||
// Calculate character offset within the line
|
||||
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.debug('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.debug('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
|
||||
}
|
||||
}, [selectionBelongsToViewer])
|
||||
|
||||
// 滚动事件处理:保存选择用于复制,但不恢复(避免选择高亮问题)
|
||||
const handleScroll = useCallback(() => {
|
||||
// 只保存选择状态用于复制,不在滚动时恢复选择
|
||||
const saved = saveSelection()
|
||||
if (saved) {
|
||||
savedSelectionRef.current = saved
|
||||
logger.debug('Selection saved for copy', {
|
||||
startLine: saved.startLine,
|
||||
endLine: saved.endLine
|
||||
})
|
||||
}
|
||||
}, [saveSelection])
|
||||
|
||||
// 处理复制事件,确保跨虚拟滚动的复制能获取完整内容
|
||||
const handleCopy = useCallback(
|
||||
(event: ClipboardEvent) => {
|
||||
const selection = window.getSelection()
|
||||
// Ignore copies for selections outside this viewer
|
||||
if (!selectionBelongsToViewer(selection)) {
|
||||
return
|
||||
}
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer saved selection from scroll, otherwise get it in real-time
|
||||
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
|
||||
|
||||
// Always use custom copy in collapsed state to handle virtual scroll edge cases
|
||||
const needsCustomCopy = !expanded
|
||||
|
||||
logger.debug('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.debug('Custom copy success', {
|
||||
linesCount: selectedLines.length,
|
||||
totalLength: fullText.length,
|
||||
firstLine: selectedLines[0]?.slice(0, 30),
|
||||
lastLine: selectedLines[selectedLines.length - 1]?.slice(0, 30)
|
||||
})
|
||||
|
||||
if (!event.clipboardData) {
|
||||
logger.warn('clipboardData unavailable, using browser default copy')
|
||||
return
|
||||
}
|
||||
event.clipboardData.setData('text/plain', fullText)
|
||||
event.preventDefault()
|
||||
} catch (error) {
|
||||
logger.error('Custom copy failed', { error })
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectionBelongsToViewer, expanded, saveSelection, rawLines]
|
||||
)
|
||||
|
||||
// Virtualizer 配置
|
||||
const getScrollElement = useCallback(() => scrollerRef.current, [])
|
||||
const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId])
|
||||
@ -147,6 +370,58 @@ const CodeViewer = ({
|
||||
}
|
||||
}, [virtualItems, debouncedHighlightLines])
|
||||
|
||||
// Monitor selection changes, clear stale selection state, and auto-expand in collapsed state
|
||||
const handleSelectionChange = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
const selection = window.getSelection()
|
||||
|
||||
// No valid selection: clear and return
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
savedSelectionRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// Only handle selections within this CodeViewer
|
||||
if (!selectionBelongsToViewer(selection)) {
|
||||
savedSelectionRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// In collapsed state, detect multi-line selection and request expand
|
||||
if (!expanded && onRequestExpand) {
|
||||
const saved = saveSelection()
|
||||
if (saved && saved.endLine > saved.startLine) {
|
||||
logger.debug('Multi-line selection detected in collapsed state, requesting expand', {
|
||||
startLine: saved.startLine,
|
||||
endLine: saved.endLine
|
||||
})
|
||||
onRequestExpand()
|
||||
}
|
||||
}
|
||||
}, 100),
|
||||
[expanded, onRequestExpand, saveSelection, selectionBelongsToViewer]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('selectionchange', handleSelectionChange)
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange)
|
||||
handleSelectionChange.cancel()
|
||||
}
|
||||
}, [handleSelectionChange])
|
||||
|
||||
// Listen for copy events
|
||||
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
|
||||
useLayoutEffect(() => {
|
||||
onHeightChange?.(scrollerRef.current?.scrollHeight ?? 0)
|
||||
@ -160,6 +435,7 @@ const CodeViewer = ({
|
||||
$wrap={wrapped}
|
||||
$expand={expanded}
|
||||
$lineHeight={estimateSize()}
|
||||
onScroll={handleScroll}
|
||||
style={
|
||||
{
|
||||
'--gutter-width': `${gutterDigits}ch`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user