mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 22:52:08 +08:00
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:
parent
dc8df98929
commit
37c2b5ecbe
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 特殊视图组件映射
|
// 特殊视图组件映射
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user