mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 21:01:32 +08:00
fix: Support right-click to paste file content into inputbar (#10730)
* feat: add right-click to paste text file content into input Implemented context menu functionality for text file attachments that allows users to right-click on a text file attachment to paste its content directly into the input field. Changes: - Added onContextMenu prop to CustomTag component for handling right-click events - Extended AttachmentPreview with onAttachmentContextMenu callback - Implemented appendTxtContentToInput function to read and paste text file content - Added clipboard support for copying file content - Integrated context menu handler in Inputbar component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * use real path * 🐛 fix: clear txt attachment after paste * ✨ fix: improve attachment confirm flow * update i18n * 🎨 refactor: restyle confirm dialog * format code * refactor(ConfirmDialog): replace text buttons with icon buttons and remove i18n - Replace text-based cancel/confirm buttons with icon buttons for better visual clarity - Remove unused i18n translation hook as it's no longer needed - Adjust styling to accommodate new button layout --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: icarus <eurfelux@gmail.com>
This commit is contained in:
parent
4cca5210b9
commit
528524b075
45
src/renderer/src/components/ConfirmDialog.tsx
Normal file
45
src/renderer/src/components/ConfirmDialog.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Button } from '@heroui/react'
|
||||
import { CheckIcon, XIcon } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
interface Props {
|
||||
x: number
|
||||
y: number
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ConfirmDialog: FC<Props> = ({ x, y, message, onConfirm, onCancel }) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99998] bg-transparent" onClick={onCancel} />
|
||||
<div
|
||||
className="-translate-x-1/2 -translate-y-full fixed z-[99999] mt-[-8px] transform"
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`
|
||||
}}>
|
||||
<div className="flex min-w-[160px] items-center rounded-lg border border-[var(--color-border)] bg-[var(--color-background)] p-3 shadow-[0_4px_12px_rgba(0,0,0,0.15)]">
|
||||
<div className="mr-2 text-sm leading-[1.4]">{message}</div>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button onPress={onCancel} radius="full" className="h-6 w-6 min-w-0 p-1" color="danger">
|
||||
<XIcon className="text-danger-foreground" size={16} />
|
||||
</Button>
|
||||
<Button onPress={onConfirm} radius="full" className="h-6 w-6 min-w-0 p-1" color="success">
|
||||
<CheckIcon className="text-success-foreground" size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmDialog
|
||||
@ -13,6 +13,7 @@ export interface CustomTagProps {
|
||||
closable?: boolean
|
||||
onClose?: () => void
|
||||
onClick?: MouseEventHandler<HTMLDivElement>
|
||||
onContextMenu?: MouseEventHandler<HTMLDivElement>
|
||||
disabled?: boolean
|
||||
inactive?: boolean
|
||||
}
|
||||
@ -27,6 +28,7 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
closable = false,
|
||||
onClose,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
disabled,
|
||||
inactive
|
||||
}) => {
|
||||
@ -39,6 +41,7 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
$closable={closable}
|
||||
$clickable={!disabled && !!onClick}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onContextMenu={disabled ? undefined : onContextMenu}
|
||||
style={{
|
||||
...(disabled && { cursor: 'not-allowed' }),
|
||||
...style
|
||||
@ -56,7 +59,7 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
)}
|
||||
</Tag>
|
||||
),
|
||||
[actualColor, children, closable, disabled, icon, onClick, onClose, size, style]
|
||||
[actualColor, children, closable, disabled, icon, onClick, onClose, onContextMenu, size, style]
|
||||
)
|
||||
|
||||
return tooltip ? (
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "Clear Context {{Command}}"
|
||||
},
|
||||
"new_topic": "New Topic {{Command}}",
|
||||
"paste_text_file_confirm": "Paste into input bar?",
|
||||
"pause": "Pause",
|
||||
"placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools",
|
||||
"placeholder_without_triggers": "Type your message here, press {{key}} to send",
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_topic": "新话题 {{Command}}",
|
||||
"paste_text_file_confirm": "粘贴到输入框?",
|
||||
"pause": "暂停",
|
||||
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具",
|
||||
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_topic": "新話題 {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "暫停",
|
||||
"placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具",
|
||||
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "Καθαρισμός ενδιάμεσων {{Command}}"
|
||||
},
|
||||
"new_topic": "Νέο θέμα {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Παύση",
|
||||
"placeholder": "Εισάγετε μήνυμα εδώ...",
|
||||
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "Limpiar contexto {{Command}}"
|
||||
},
|
||||
"new_topic": "Nuevo tema {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Pausar",
|
||||
"placeholder": "Escribe aquí tu mensaje...",
|
||||
"placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar",
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "Effacer le contexte {{Command}}"
|
||||
},
|
||||
"new_topic": "Nouveau sujet {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Pause",
|
||||
"placeholder": "Entrez votre message ici...",
|
||||
"placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer",
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "コンテキストをクリア {{Command}}"
|
||||
},
|
||||
"new_topic": "新しいトピック {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "一時停止",
|
||||
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "Limpar contexto {{Command}}"
|
||||
},
|
||||
"new_topic": "Novo tópico {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Pausar",
|
||||
"placeholder": "Digite sua mensagem aqui...",
|
||||
"placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar",
|
||||
|
||||
@ -538,6 +538,7 @@
|
||||
"context": "Очистить контекст {{Command}}"
|
||||
},
|
||||
"new_topic": "Новый топик {{Command}}",
|
||||
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
|
||||
"pause": "Остановить",
|
||||
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
|
||||
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
GlobalOutlined,
|
||||
LinkOutlined
|
||||
} from '@ant-design/icons'
|
||||
import ConfirmDialog from '@renderer/components/ConfirmDialog'
|
||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||
import { useAttachment } from '@renderer/hooks/useAttachment'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
@ -19,12 +20,14 @@ import { FileMetadata } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Flex, Image, Tooltip } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, MouseEvent, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
files: FileMetadata[]
|
||||
setFiles: (files: FileMetadata[]) => void
|
||||
onAttachmentContextMenu?: (file: FileMetadata, event: MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
const MAX_FILENAME_DISPLAY_LENGTH = 20
|
||||
@ -133,24 +136,91 @@ export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
|
||||
const AttachmentPreview: FC<Props> = ({ files, setFiles, onAttachmentContextMenu }) => {
|
||||
const { t } = useTranslation()
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
file: FileMetadata
|
||||
x: number
|
||||
y: number
|
||||
} | null>(null)
|
||||
|
||||
const handleContextMenu = async (file: FileMetadata, event: MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// 获取被点击元素的位置
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
|
||||
// 计算对话框位置:附件标签的中心位置
|
||||
const x = rect.left + rect.width / 2
|
||||
const y = rect.top
|
||||
|
||||
try {
|
||||
const isText = await window.api.file.isTextFile(file.path)
|
||||
if (!isText) {
|
||||
setContextMenu(null)
|
||||
return
|
||||
}
|
||||
|
||||
setContextMenu({
|
||||
file,
|
||||
x,
|
||||
y
|
||||
})
|
||||
} catch (error) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (contextMenu && onAttachmentContextMenu) {
|
||||
// Create a synthetic mouse event for the callback
|
||||
const syntheticEvent = {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as MouseEvent<HTMLDivElement>
|
||||
onAttachmentContextMenu(contextMenu.file, syntheticEvent)
|
||||
}
|
||||
setContextMenu(null)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setContextMenu(null)
|
||||
}
|
||||
|
||||
if (isEmpty(files)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
{files.map((file) => (
|
||||
<CustomTag
|
||||
key={file.id}
|
||||
icon={getFileIcon(file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => setFiles(files.filter((f) => f.id !== file.id))}>
|
||||
<FileNameRender file={file} />
|
||||
</CustomTag>
|
||||
))}
|
||||
</ContentContainer>
|
||||
<>
|
||||
<ContentContainer>
|
||||
{files.map((file) => (
|
||||
<CustomTag
|
||||
key={file.id}
|
||||
icon={getFileIcon(file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => setFiles(files.filter((f) => f.id !== file.id))}
|
||||
onContextMenu={(event) => {
|
||||
void handleContextMenu(file, event)
|
||||
}}>
|
||||
<FileNameRender file={file} />
|
||||
</CustomTag>
|
||||
))}
|
||||
</ContentContainer>
|
||||
|
||||
{contextMenu && (
|
||||
<ConfirmDialog
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
message={t('chat.input.paste_text_file_confirm')}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -293,6 +293,53 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea])
|
||||
|
||||
const appendTxtContentToInput = useCallback(
|
||||
async (file: FileType, event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
try {
|
||||
const targetPath = file.path
|
||||
const content = await window.api.file.readExternal(targetPath, true)
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
} catch (clipboardError) {
|
||||
logger.warn('Failed to copy txt attachment content to clipboard:', clipboardError as Error)
|
||||
}
|
||||
|
||||
setText((prev) => {
|
||||
if (!prev) {
|
||||
return content
|
||||
}
|
||||
|
||||
const needsSeparator = !prev.endsWith('\n')
|
||||
return needsSeparator ? `${prev}\n${content}` : prev + content
|
||||
})
|
||||
|
||||
setFiles((prev) => prev.filter((currentFile) => currentFile.id !== file.id))
|
||||
|
||||
setTimeoutTimer(
|
||||
'appendTxtAttachment',
|
||||
() => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const end = textArea.value.length
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(end, end)
|
||||
}
|
||||
|
||||
resizeTextArea(true)
|
||||
},
|
||||
0
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to append txt attachment content:', error as Error)
|
||||
window.toast.error(t('chat.input.file_error'))
|
||||
}
|
||||
},
|
||||
[resizeTextArea, setTimeoutTimer, t]
|
||||
)
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// 按下Tab键,自动选中${xxx}
|
||||
if (event.key === 'Tab' && inputFocus) {
|
||||
@ -831,7 +878,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
id="inputbar"
|
||||
className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')}
|
||||
ref={containerRef}>
|
||||
{files.length > 0 && <AttachmentPreview files={files} setFiles={setFiles} />}
|
||||
{files.length > 0 && (
|
||||
<AttachmentPreview files={files} setFiles={setFiles} onAttachmentContextMenu={appendTxtContentToInput} />
|
||||
)}
|
||||
{selectedKnowledgeBases.length > 0 && (
|
||||
<KnowledgeBaseInput
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user