mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
refactor: centralize paste handling logic with PasteService (#6199)
- Integrated PasteService for handling paste events in Inputbar and MessageEditor components. - Removed redundant paste handling code and improved maintainability. - Registered paste handlers and set last focused component for better user experience. - Ensured consistent behavior for text and file pasting across components. Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
This commit is contained in:
parent
2fc705c77f
commit
3908080d21
@ -23,6 +23,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
@ -581,72 +582,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
|
||||
const onPaste = useCallback(
|
||||
async (event: ClipboardEvent) => {
|
||||
// 优先处理文本粘贴
|
||||
const clipboardText = event.clipboardData?.getData('text')
|
||||
if (clipboardText) {
|
||||
// 1. 文本粘贴
|
||||
if (pasteLongTextAsFile && clipboardText.length > pasteLongTextThreshold) {
|
||||
// 长文本直接转文件,阻止默认粘贴
|
||||
event.preventDefault()
|
||||
|
||||
const tempFilePath = await window.api.file.create('pasted_text.txt')
|
||||
await window.api.file.write(tempFilePath, clipboardText)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
setText(text) // 保持输入框内容不变
|
||||
setTimeout(() => resizeTextArea(), 50)
|
||||
return
|
||||
}
|
||||
// 短文本走默认粘贴行为,直接返回
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 文件/图片粘贴(仅在无文本时处理)
|
||||
if (event.clipboardData?.files && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault()
|
||||
for (const file of event.clipboardData.files) {
|
||||
try {
|
||||
// 使用新的API获取文件路径
|
||||
const filePath = window.api.file.getPathForFile(file)
|
||||
|
||||
// 如果没有路径,可能是剪贴板中的图像数据
|
||||
if (!filePath) {
|
||||
// 图像生成也支持图像编辑
|
||||
if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) {
|
||||
const tempFilePath = await window.api.file.create(file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await window.api.file.write(tempFilePath, uint8Array)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
break
|
||||
} else {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 有路径的情况
|
||||
if (supportExts.includes(getFileExtension(filePath))) {
|
||||
const selectedFile = await window.api.file.get(filePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
} else {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] onPaste:', error)
|
||||
window.message.error(t('chat.input.file_error'))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// 其他情况默认粘贴
|
||||
return await PasteService.handlePaste(
|
||||
event,
|
||||
isVisionModel(model),
|
||||
isGenerateImageModel(model),
|
||||
supportExts,
|
||||
setFiles,
|
||||
setText,
|
||||
pasteLongTextAsFile,
|
||||
pasteLongTextThreshold,
|
||||
text,
|
||||
resizeTextArea,
|
||||
t
|
||||
)
|
||||
},
|
||||
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t, text]
|
||||
)
|
||||
@ -749,6 +697,20 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [isDragging, handleDrag, handleDragEnd])
|
||||
|
||||
// 注册粘贴处理函数并初始化全局监听
|
||||
useEffect(() => {
|
||||
// 确保全局paste监听器仅初始化一次
|
||||
PasteService.init()
|
||||
|
||||
// 注册当前组件的粘贴处理函数
|
||||
PasteService.registerHandler('inputbar', onPaste)
|
||||
|
||||
// 卸载时取消注册
|
||||
return () => {
|
||||
PasteService.unregisterHandler('inputbar')
|
||||
}
|
||||
}, [onPaste])
|
||||
|
||||
useShortcut('new_topic', () => {
|
||||
addNewTopic()
|
||||
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
||||
@ -951,6 +913,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
setInputFocus(true)
|
||||
// 记录当前聚焦的组件
|
||||
PasteService.setLastFocusedComponent('inputbar')
|
||||
if (e.target.value.length === 0) {
|
||||
e.target.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { classNames, getFileExtension } from '@renderer/utils'
|
||||
@ -62,6 +63,39 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPaste = useCallback(
|
||||
async (event: ClipboardEvent) => {
|
||||
return await PasteService.handlePaste(
|
||||
event,
|
||||
isVisionModel(model),
|
||||
isGenerateImageModel(model),
|
||||
supportExts,
|
||||
setFiles,
|
||||
undefined, // 不需要setText
|
||||
pasteLongTextAsFile,
|
||||
pasteLongTextThreshold,
|
||||
undefined, // 不需要text
|
||||
resizeTextArea,
|
||||
t
|
||||
)
|
||||
},
|
||||
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t]
|
||||
)
|
||||
|
||||
// 添加全局粘贴事件处理
|
||||
useEffect(() => {
|
||||
// 注册当前组件的粘贴处理函数
|
||||
PasteService.registerHandler('messageEditor', onPaste)
|
||||
|
||||
// 在组件加载时将焦点设置为当前组件
|
||||
PasteService.setLastFocusedComponent('messageEditor')
|
||||
|
||||
// 卸载时取消注册
|
||||
return () => {
|
||||
PasteService.unregisterHandler('messageEditor')
|
||||
}
|
||||
}, [onPaste])
|
||||
|
||||
const handleTextChange = (blockId: string, content: string) => {
|
||||
setEditedBlocks((prev) => prev.map((block) => (block.id === blockId ? { ...block, content } : block)))
|
||||
}
|
||||
@ -131,67 +165,6 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
}
|
||||
}
|
||||
|
||||
const onPaste = useCallback(
|
||||
async (event: ClipboardEvent) => {
|
||||
// 1. 文本粘贴
|
||||
const clipboardText = event.clipboardData?.getData('text')
|
||||
if (clipboardText) {
|
||||
if (pasteLongTextAsFile && clipboardText.length > pasteLongTextThreshold) {
|
||||
// 长文本直接转文件,阻止默认粘贴
|
||||
event.preventDefault()
|
||||
|
||||
const tempFilePath = await window.api.file.create('pasted_text.txt')
|
||||
await window.api.file.write(tempFilePath, clipboardText)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
setTimeout(() => resizeTextArea(), 50)
|
||||
return
|
||||
}
|
||||
// 短文本走默认粘贴行为,直接返回
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 文件/图片粘贴
|
||||
if (event.clipboardData?.files && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault()
|
||||
for (const file of event.clipboardData.files) {
|
||||
const filePath = window.api.file.getPathForFile(file)
|
||||
if (!filePath) {
|
||||
// 图像生成也支持图像编辑
|
||||
if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) {
|
||||
const tempFilePath = await window.api.file.create(file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await window.api.file.write(tempFilePath, uint8Array)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
break
|
||||
} else {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (supportExts.includes(getFileExtension(filePath))) {
|
||||
const selectedFile = await window.api.file.get(filePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
} else {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 短文本走默认粘贴行为
|
||||
},
|
||||
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t]
|
||||
)
|
||||
|
||||
const autoResizeTextArea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = e.target
|
||||
textarea.style.height = 'auto'
|
||||
@ -205,7 +178,7 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
.map((block) => (
|
||||
<Textarea
|
||||
className={classNames(isFileDragging && 'file-dragging')}
|
||||
className={classNames('editing-message', isFileDragging && 'file-dragging')}
|
||||
key={block.id}
|
||||
ref={textareaRef}
|
||||
variant="borderless"
|
||||
@ -218,6 +191,10 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
contextMenu="true"
|
||||
spellCheck={false}
|
||||
onPaste={(e) => onPaste(e.nativeEvent)}
|
||||
onFocus={() => {
|
||||
// 记录当前聚焦的组件
|
||||
PasteService.setLastFocusedComponent('messageEditor')
|
||||
}}
|
||||
style={{
|
||||
fontSize,
|
||||
padding: '0px 15px 8px 15px'
|
||||
|
||||
215
src/renderer/src/services/PasteService.ts
Normal file
215
src/renderer/src/services/PasteService.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { getFileExtension } from '@renderer/utils'
|
||||
|
||||
// Track last focused component
|
||||
type ComponentType = 'inputbar' | 'messageEditor' | null
|
||||
let lastFocusedComponent: ComponentType = 'inputbar' // Default to inputbar
|
||||
|
||||
// 处理函数类型
|
||||
type PasteHandler = (event: ClipboardEvent) => Promise<boolean>
|
||||
|
||||
// 处理函数存储
|
||||
const handlers: {
|
||||
inputbar?: PasteHandler
|
||||
messageEditor?: PasteHandler
|
||||
} = {}
|
||||
|
||||
// 初始化标志
|
||||
let isInitialized = false
|
||||
|
||||
/**
|
||||
* 处理粘贴事件的通用服务
|
||||
* 处理各种粘贴场景,包括文本和文件
|
||||
*/
|
||||
export const handlePaste = async (
|
||||
event: ClipboardEvent,
|
||||
isVisionModel: boolean,
|
||||
isGenerateImageModel: boolean,
|
||||
supportExts: string[],
|
||||
setFiles: (updater: (prevFiles: FileType[]) => FileType[]) => void,
|
||||
setText?: (text: string) => void,
|
||||
pasteLongTextAsFile?: boolean,
|
||||
pasteLongTextThreshold?: number,
|
||||
text?: string,
|
||||
resizeTextArea?: () => void,
|
||||
t?: (key: string) => string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
// 优先处理文本粘贴
|
||||
const clipboardText = event.clipboardData?.getData('text')
|
||||
if (clipboardText) {
|
||||
// 1. 文本粘贴
|
||||
if (pasteLongTextAsFile && pasteLongTextThreshold && clipboardText.length > pasteLongTextThreshold) {
|
||||
// 长文本直接转文件,阻止默认粘贴
|
||||
event.preventDefault()
|
||||
|
||||
const tempFilePath = await window.api.file.create('pasted_text.txt')
|
||||
await window.api.file.write(tempFilePath, clipboardText)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
if (selectedFile) {
|
||||
setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
if (setText && text) setText(text) // 保持输入框内容不变
|
||||
if (resizeTextArea) setTimeout(() => resizeTextArea(), 50)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 短文本走默认粘贴行为,直接返回
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. 文件/图片粘贴(仅在无文本时处理)
|
||||
if (event.clipboardData?.files && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault()
|
||||
try {
|
||||
for (const file of event.clipboardData.files) {
|
||||
// 使用新的API获取文件路径
|
||||
const filePath = window.api.file.getPathForFile(file)
|
||||
|
||||
// 如果没有路径,可能是剪贴板中的图像数据
|
||||
if (!filePath) {
|
||||
// 图像生成也支持图像编辑
|
||||
if (file.type.startsWith('image/') && (isVisionModel || isGenerateImageModel)) {
|
||||
const tempFilePath = await window.api.file.create(file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await window.api.file.write(tempFilePath, uint8Array)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
if (selectedFile) {
|
||||
setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (t) {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 有路径的情况
|
||||
if (supportExts.includes(getFileExtension(filePath))) {
|
||||
const selectedFile = await window.api.file.get(filePath)
|
||||
if (selectedFile) {
|
||||
setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
}
|
||||
} else {
|
||||
if (t) {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[PasteService] onPaste:', error)
|
||||
if (t) {
|
||||
window.message.error(t('chat.input.file_error'))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 其他情况默认粘贴
|
||||
return false
|
||||
} catch (error) {
|
||||
Logger.error('[PasteService] handlePaste error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最后聚焦的组件
|
||||
*/
|
||||
export const setLastFocusedComponent = (component: ComponentType) => {
|
||||
lastFocusedComponent = component
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后聚焦的组件
|
||||
*/
|
||||
export const getLastFocusedComponent = (): ComponentType => {
|
||||
return lastFocusedComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化全局粘贴事件监听
|
||||
* 应用启动时只调用一次
|
||||
*/
|
||||
export const init = () => {
|
||||
if (isInitialized) return
|
||||
|
||||
// 添加全局粘贴事件监听
|
||||
document.addEventListener('paste', async (event) => {
|
||||
await handleGlobalPaste(event)
|
||||
})
|
||||
|
||||
isInitialized = true
|
||||
Logger.info('[PasteService] Global paste handler initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册组件的粘贴处理函数
|
||||
*/
|
||||
export const registerHandler = (component: ComponentType, handler: PasteHandler) => {
|
||||
if (!component) return
|
||||
|
||||
handlers[component] = handler
|
||||
Logger.info(`[PasteService] Handler registered for ${component}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除组件的粘贴处理函数
|
||||
*/
|
||||
export const unregisterHandler = (component: ComponentType) => {
|
||||
if (!component || !handlers[component]) return
|
||||
|
||||
delete handlers[component]
|
||||
Logger.info(`[PasteService] Handler unregistered for ${component}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局粘贴处理函数,根据最后聚焦的组件路由粘贴事件
|
||||
*/
|
||||
const handleGlobalPaste = async (event: ClipboardEvent): Promise<boolean> => {
|
||||
// 如果当前有活动元素且是输入区域,不执行全局处理
|
||||
const activeElement = document.activeElement
|
||||
if (
|
||||
activeElement &&
|
||||
(activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.getAttribute('contenteditable') === 'true')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 根据最后聚焦的组件调用相应处理程序
|
||||
if (lastFocusedComponent && handlers[lastFocusedComponent]) {
|
||||
const handler = handlers[lastFocusedComponent]
|
||||
if (handler) {
|
||||
return await handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配的处理程序,默认使用inputbar处理
|
||||
if (handlers.inputbar) {
|
||||
const handler = handlers.inputbar
|
||||
if (handler) {
|
||||
return await handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export default {
|
||||
handlePaste,
|
||||
setLastFocusedComponent,
|
||||
getLastFocusedComponent,
|
||||
init,
|
||||
registerHandler,
|
||||
unregisterHandler
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user