From a03c1346a451ca035f51d71c2e5ceb1387aa93c3 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 20 Oct 2025 13:16:17 +0800 Subject: [PATCH] fix: Support right-click to paste file content into inputbar (#10730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 Co-authored-by: icarus (cherry picked from commit 528524b075fbf9d17aed7394a6e859eab932c650) --- src/renderer/src/components/ConfirmDialog.tsx | 45 +++++++++ .../src/components/Tags/CustomTag.tsx | 5 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/ja-jp.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + src/renderer/src/i18n/translate/ru-ru.json | 1 + .../pages/home/Inputbar/AttachmentPreview.tsx | 98 ++++++++++++++++--- .../src/pages/home/Inputbar/Inputbar.tsx | 51 +++++++++- 13 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/ConfirmDialog.tsx diff --git a/src/renderer/src/components/ConfirmDialog.tsx b/src/renderer/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000000..3f2313b178 --- /dev/null +++ b/src/renderer/src/components/ConfirmDialog.tsx @@ -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 = ({ x, y, message, onConfirm, onCancel }) => { + if (typeof document === 'undefined') { + return null + } + + return createPortal( + <> +
+
+
+
{message}
+
+ + +
+
+
+ , + document.body + ) +} + +export default ConfirmDialog diff --git a/src/renderer/src/components/Tags/CustomTag.tsx b/src/renderer/src/components/Tags/CustomTag.tsx index 3ecba381d4..4873a3ba7d 100644 --- a/src/renderer/src/components/Tags/CustomTag.tsx +++ b/src/renderer/src/components/Tags/CustomTag.tsx @@ -13,6 +13,7 @@ export interface CustomTagProps { closable?: boolean onClose?: () => void onClick?: MouseEventHandler + onContextMenu?: MouseEventHandler disabled?: boolean inactive?: boolean } @@ -27,6 +28,7 @@ const CustomTag: FC = ({ closable = false, onClose, onClick, + onContextMenu, disabled, inactive }) => { @@ -39,6 +41,7 @@ const CustomTag: FC = ({ $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 = ({ )} ), - [actualColor, children, closable, disabled, icon, onClick, onClose, size, style] + [actualColor, children, closable, disabled, icon, onClick, onClose, onContextMenu, size, style] ) return tooltip ? ( diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9f380b62f0..ec969bd365 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 917b5a6222..ab83dd3989 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -332,6 +332,7 @@ "context": "清除上下文 {{Command}}" }, "new_topic": "新话题 {{Command}}", + "paste_text_file_confirm": "粘贴到输入框?", "pause": "暂停", "placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具", "placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index b188601b45..ec81f264ef 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -332,6 +332,7 @@ "context": "清除上下文 {{Command}}" }, "new_topic": "新話題 {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "暫停", "placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具", "placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index b41fcd60ee..7c38404362 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -332,6 +332,7 @@ "context": "Καθαρισμός ενδιάμεσων {{Command}}" }, "new_topic": "Νέο θέμα {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "Παύση", "placeholder": "Εισάγετε μήνυμα εδώ...", "placeholder_without_triggers": "Εδώ εισαγάγετε το μήνυμα, πατήστε {{key}} για αποστολή", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 3f1579827b..c585e38e67 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -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", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 1d84c8ea05..b967d2d540 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -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", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index a80d512754..bb52d6b779 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -332,6 +332,7 @@ "context": "コンテキストをクリア {{Command}}" }, "new_topic": "新しいトピック {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "一時停止", "placeholder": "ここにメッセージを入力し、{{key}} を押して送信...", "placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信してください", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 2be7e34c3d..ac467d7e37 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -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", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 482af772f2..13ed7e57e7 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -332,6 +332,7 @@ "context": "Очистить контекст {{Command}}" }, "new_topic": "Новый топик {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "Остановить", "placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...", "placeholder_without_triggers": "Введите сообщение здесь, нажмите {{key}}, чтобы отправить", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx index 27fe399c8e..b6962c0f95 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx @@ -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) => void } const MAX_FILENAME_DISPLAY_LENGTH = 20 @@ -133,24 +136,91 @@ export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => { ) } -const AttachmentPreview: FC = ({ files, setFiles }) => { +const AttachmentPreview: FC = ({ 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) => { + 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 + onAttachmentContextMenu(contextMenu.file, syntheticEvent) + } + setContextMenu(null) + } + + const handleCancel = () => { + setContextMenu(null) + } + if (isEmpty(files)) { return null } return ( - - {files.map((file) => ( - setFiles(files.filter((f) => f.id !== file.id))}> - - - ))} - + <> + + {files.map((file) => ( + setFiles(files.filter((f) => f.id !== file.id))} + onContextMenu={(event) => { + void handleContextMenu(file, event) + }}> + + + ))} + + + {contextMenu && ( + + )} + ) } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 282f62656d..0206053eb8 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -293,6 +293,53 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea]) + const appendTxtContentToInput = useCallback( + async (file: FileType, event: React.MouseEvent) => { + 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) => { // 按下Tab键,自动选中${xxx} if (event.key === 'Tab' && inputFocus) { @@ -831,7 +878,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = id="inputbar" className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')} ref={containerRef}> - {files.length > 0 && } + {files.length > 0 && ( + + )} {selectedKnowledgeBases.length > 0 && (