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>
(cherry picked from commit 528524b075)
This commit is contained in:
beyondkmp 2025-10-20 13:16:17 +08:00 committed by dev
parent b310527210
commit a03c1346a4
13 changed files with 192 additions and 16 deletions

View 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

View File

@ -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 ? (

View File

@ -332,6 +332,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",

View File

@ -332,6 +332,7 @@
"context": "清除上下文 {{Command}}"
},
"new_topic": "新话题 {{Command}}",
"paste_text_file_confirm": "粘贴到输入框?",
"pause": "暂停",
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具",
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",

View File

@ -332,6 +332,7 @@
"context": "清除上下文 {{Command}}"
},
"new_topic": "新話題 {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "暫停",
"placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具",
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",

View File

@ -332,6 +332,7 @@
"context": "Καθαρισμός ενδιάμεσων {{Command}}"
},
"new_topic": "Νέο θέμα {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "Παύση",
"placeholder": "Εισάγετε μήνυμα εδώ...",
"placeholder_without_triggers": "Εδώ εισαγάγετε το μήνυμα, πατήστε {{key}} για αποστολή",

View File

@ -332,6 +332,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": "Escriba un mensaje aquí y presione {{key}} para enviar",

View File

@ -332,6 +332,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": "Entrez votre message ici, appuyez sur {{key}} pour envoyer",

View File

@ -332,6 +332,7 @@
"context": "コンテキストをクリア {{Command}}"
},
"new_topic": "新しいトピック {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "一時停止",
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信してください",

View File

@ -332,6 +332,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": "Digite a mensagem aqui, pressione {{key}} para enviar",

View File

@ -332,6 +332,7 @@
"context": "Очистить контекст {{Command}}"
},
"new_topic": "Новый топик {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "Остановить",
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
"placeholder_without_triggers": "Введите сообщение здесь, нажмите {{key}}, чтобы отправить",

View File

@ -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}
/>
)}
</>
)
}

View File

@ -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}