From 78c6f9724865f3d566fe2c66e5684f677f8f6ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Sun, 25 Jan 2026 13:38:13 +0800 Subject: [PATCH] refactor: simplify translate action by removing dual language selector (#12343) * feat: enhance ActionTranslate component with language detection and settings dropdown - Added state management for detected and actual target languages. - Implemented a settings dropdown for selecting preferred and alternate languages. - Updated UI to display detected language and integrated new settings for language selection. - Refactored language change handling to improve user experience and maintain state consistency. * refactor(ActionTranslate): reorganize layout and improve component structure - Introduced LeftGroup for better alignment of detected language, target language selector, and settings dropdown. - Removed unnecessary Spacer component to streamline the layout. - Enhanced styling for improved visual consistency and user experience. * fix(ActionTranslate): simplify loading state display for detected language - Removed loading spinner and adjusted the display for the detected language during the detection process to enhance clarity and reduce visual clutter. --- src/renderer/src/i18n/locales/en-us.json | 4 + src/renderer/src/i18n/locales/zh-cn.json | 4 + src/renderer/src/i18n/locales/zh-tw.json | 4 + src/renderer/src/i18n/translate/de-de.json | 4 + src/renderer/src/i18n/translate/el-gr.json | 4 + src/renderer/src/i18n/translate/es-es.json | 4 + src/renderer/src/i18n/translate/fr-fr.json | 4 + src/renderer/src/i18n/translate/ja-jp.json | 4 + src/renderer/src/i18n/translate/pt-pt.json | 4 + src/renderer/src/i18n/translate/ro-ro.json | 4 + src/renderer/src/i18n/translate/ru-ru.json | 4 + .../action/components/ActionTranslate.tsx | 245 +++++++++++++++--- 12 files changed, 247 insertions(+), 42 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b935579811..0016a9b7bc 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -5208,6 +5208,8 @@ "detected": { "language": "Auto Detect" }, + "detected_source": "Detected", + "detecting": "Detecting...", "empty": "Translation content is empty", "error": { "chat_qwen_mt": "Qwen MT model cannot be used in chat. Please go to the translation page.", @@ -5260,6 +5262,7 @@ "not_pair": "Source language is different from the set language", "same": "Source and target languages are the same" }, + "language_settings": "Language Settings", "menu": { "description": "Translate the content of the current input box" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "Translation" }, + "preferred_target": "Preferred Target", "processing": "Translation in progress...", "settings": { "autoCopy": "Copy after translation ", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0a089817bc..7954e5a3eb 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -5208,6 +5208,8 @@ "detected": { "language": "自动检测" }, + "detected_source": "检测到", + "detecting": "检测中...", "empty": "翻译内容为空", "error": { "chat_qwen_mt": "Qwen MT 模型不可在对话中使用,请转至翻译页面", @@ -5260,6 +5262,7 @@ "not_pair": "源语言与设置的语言不同", "same": "源语言和目标语言相同" }, + "language_settings": "语言设置", "menu": { "description": "对当前输入框内容进行翻译" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "翻译" }, + "preferred_target": "首选目标", "processing": "翻译中...", "settings": { "autoCopy": "翻译完成后自动复制", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 53444a3530..94ae9fd3e8 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -5208,6 +5208,8 @@ "detected": { "language": "自動偵測" }, + "detected_source": "偵測到", + "detecting": "偵測中...", "empty": "翻譯內容為空", "error": { "chat_qwen_mt": "Qwen MT 模型無法在對話中使用,請前往翻譯頁面", @@ -5260,6 +5262,7 @@ "not_pair": "來源語言與設定的語言不同", "same": "來源語言和目標語言相同" }, + "language_settings": "語言設定", "menu": { "description": "對目前輸入框內容進行翻譯" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "翻譯" }, + "preferred_target": "首選目標", "processing": "翻譯中...", "settings": { "autoCopy": "翻譯完成後自動複製", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 8ffd31d8e0..f2f4674140 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -5208,6 +5208,8 @@ "detected": { "language": "Automatische Erkennung" }, + "detected_source": "Erfasst", + "detecting": "Erkenne...", "empty": "Übersetzungsinhalt leer", "error": { "chat_qwen_mt": "Qwen MT-Modell kann nicht in der Konversation verwendet werden, bitte gehen Sie zur Übersetzungsseite", @@ -5260,6 +5262,7 @@ "not_pair": "Quellsprache unterscheidet sich von eingestellter Sprache", "same": "Quell- und Zielsprache sind identisch" }, + "language_settings": "Spracheinstellungen", "menu": { "description": "Inhalt des aktuellen Eingabefelds übersetzen" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "Übersetzen" }, + "preferred_target": "Bevorzugtes Ziel", "processing": "Wird übersetzt...", "settings": { "autoCopy": "Nach Übersetzung automatisch kopieren", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index c546197431..713d914cfa 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -5208,6 +5208,8 @@ "detected": { "language": "Αυτόματη ανίχνευση" }, + "detected_source": "Εντοπίστηκε", + "detecting": "Ανίχνευση...", "empty": "Το μεταφρασμένο κείμενο είναι κενό", "error": { "chat_qwen_mt": "Τα μοντέλα Qwen MT δεν είναι διαθέσιμα για χρήση σε διαλόγους, παρακαλώ μεταβείτε στη σελίδα μετάφρασης", @@ -5260,6 +5262,7 @@ "not_pair": "Η γλώσσα πηγής διαφέρει από την οριζόμενη γλώσσα", "same": "Η γλώσσα πηγής και η γλώσσα προορισμού είναι ίδιες" }, + "language_settings": "Ρυθμίσεις Γλώσσας", "menu": { "description": "Μεταφράστε το περιεχόμενο του τρέχοντος πεδίου εισαγωγής" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "Μετάφραση" }, + "preferred_target": "Προτιμώμενος Στόχος", "processing": "Μεταφράζεται...", "settings": { "autoCopy": "Μετά τη μετάφραση, αντιγράφεται αυτόματα", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 28ec6a2cc5..7bfe1d5819 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -5208,6 +5208,8 @@ "detected": { "language": "Detección automática" }, + "detected_source": "Detectado", + "detecting": "Detectando...", "empty": "El contenido de traducción está vacío", "error": { "chat_qwen_mt": "El modelo Qwen MT no está disponible para uso en conversaciones, por favor vaya a la página de traducción.", @@ -5260,6 +5262,7 @@ "not_pair": "El idioma de origen es diferente al idioma configurado", "same": "El idioma de origen y el idioma de destino son iguales" }, + "language_settings": "Configuración de idioma", "menu": { "description": "Traducir el contenido del campo de entrada actual" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "Traducción" }, + "preferred_target": "Objetivo Preferido", "processing": "Traduciendo...", "settings": { "autoCopy": "Copiar automáticamente después de completar la traducción", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 291fa24109..5481d1b283 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -5208,6 +5208,8 @@ "detected": { "language": "Détection automatique" }, + "detected_source": "Détecté", + "detecting": "Détection...", "empty": "Le contenu à traduire est vide", "error": { "chat_qwen_mt": "Les modèles Qwen MT ne peuvent pas être utilisés dans les conversations, veuillez vous rendre sur la page de traduction.", @@ -5260,6 +5262,7 @@ "not_pair": "La langue source est différente de la langue définie", "same": "La langue source et la langue cible sont identiques" }, + "language_settings": "Paramètres de langue", "menu": { "description": "Traduire le contenu de la zone de saisie actuelle" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "traduction" }, + "preferred_target": "Cible préférée", "processing": "en cours de traduction...", "settings": { "autoCopy": "Copié automatiquement après la traduction", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index ae0d7ce79b..4dd15b1401 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -5208,6 +5208,8 @@ "detected": { "language": "自動検出" }, + "detected_source": "検出されました", + "detecting": "検出中...", "empty": "翻訳内容が空です", "error": { "chat_qwen_mt": "Qwen MT モデルは対話で使用できません。翻訳ページに移動してください", @@ -5260,6 +5262,7 @@ "not_pair": "ソース言語が設定された言語と異なります", "same": "ソース言語と目標言語が同じです" }, + "language_settings": "言語設定", "menu": { "description": "對當前輸入框內容進行翻譯" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "翻訳" }, + "preferred_target": "優先ターゲット", "processing": "翻訳中...", "settings": { "autoCopy": "翻訳完了後、自動的にコピー", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index fb42a5ac95..6d25bc9495 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -5208,6 +5208,8 @@ "detected": { "language": "Detecção automática" }, + "detected_source": "Detectado", + "detecting": "Detectando...", "empty": "O conteúdo de tradução está vazio", "error": { "chat_qwen_mt": "Modelos Qwen MT não estão disponíveis para uso em conversas. Por favor, vá para a página de tradução.", @@ -5260,6 +5262,7 @@ "not_pair": "O idioma de origem é diferente do idioma definido", "same": "O idioma de origem e o idioma de destino são iguais" }, + "language_settings": "Configurações de Idioma", "menu": { "description": "Traduzir o conteúdo da caixa de entrada atual" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "Tradução" }, + "preferred_target": "Alvo Preferencial", "processing": "Traduzindo...", "settings": { "autoCopy": "Cópia automática após a tradução", diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index 6a4114c5bc..361c74b368 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -5208,6 +5208,8 @@ "detected": { "language": "Detectare automată" }, + "detected_source": "Detectat", + "detecting": "Se detectează...", "empty": "Conținutul traducerii este gol", "error": { "chat_qwen_mt": "Modelul Qwen MT nu poate fi folosit în chat. Te rugăm să mergi la pagina de traducere.", @@ -5260,6 +5262,7 @@ "not_pair": "Limba sursă este diferită de limba setată", "same": "Limbile sursă și țintă sunt aceleași" }, + "language_settings": "Setări limbă", "menu": { "description": "Tradu conținutul casetei de intrare curente" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "Traducere" }, + "preferred_target": "Țintă Preferată", "processing": "Traducere în curs...", "settings": { "autoCopy": "Copiază după traducere ", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index ab9ed70627..837a371429 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -5208,6 +5208,8 @@ "detected": { "language": "Автоматическое обнаружение" }, + "detected_source": "Обнаружено", + "detecting": "Обнаружение...", "empty": "Содержимое перевода пусто", "error": { "chat_qwen_mt": "Модель Qwen MT недоступна для использования в диалоге, перейдите на страницу перевода", @@ -5260,6 +5262,7 @@ "not_pair": "Исходный язык отличается от настроенного", "same": "Исходный и целевой языки совпадают" }, + "language_settings": "Языковые настройки", "menu": { "description": "Перевести содержимое текущего ввода" }, @@ -5269,6 +5272,7 @@ "output": { "placeholder": "Перевод" }, + "preferred_target": "Предпочтительная цель", "processing": "Перевод в процессе...", "settings": { "autoCopy": "Автоматически копировать после завершения перевода", diff --git a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx index a5ce31bab7..9bd1eb0803 100644 --- a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx @@ -15,12 +15,12 @@ import { AssistantMessageStatus } from '@renderer/types/newMessage' import type { ActionItem } from '@renderer/types/selectionTypes' import { abortCompletion } from '@renderer/utils/abortController' import { detectLanguage } from '@renderer/utils/translate' -import { Tooltip } from 'antd' -import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react' +import { Dropdown, Tooltip } from 'antd' +import { ArrowRight, ChevronDown, CircleHelp, Settings2 } from 'lucide-react' import type { FC } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' +import styled, { createGlobalStyle } from 'styled-components' import { processMessages } from './ActionUtils' import WindowFooter from './WindowFooter' @@ -47,12 +47,15 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { }) const [alterLanguage, setAlterLanguage] = useState(LanguagesEnum.enUS) + const [detectedLanguage, setDetectedLanguage] = useState(null) + const [actualTargetLanguage, setActualTargetLanguage] = useState(targetLanguage) const [error, setError] = useState('') const [showOriginal, setShowOriginal] = useState(false) const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing') const [contentToCopy, setContentToCopy] = useState('') const [initialized, setInitialized] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) // Use useRef for values that shouldn't trigger re-renders const assistantRef = useRef(null) @@ -156,6 +159,10 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { return } + // Set detected language for UI display + const detectedLang = getLanguageByLangcode(sourceLanguageCode) + setDetectedLanguage(detectedLang) + let translateLang: TranslateLanguage if (sourceLanguageCode === UNKNOWN.langCode) { @@ -170,11 +177,14 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { } } + // Set actual target language for UI display + setActualTargetLanguage(translateLang) + const assistant = getDefaultTranslateAssistant(translateLang, action.selectedText) assistantRef.current = assistant logger.debug('process once') processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError) - }, [action, targetLanguage, alterLanguage, scrollToBottom, initialized]) + }, [action, targetLanguage, alterLanguage, scrollToBottom, initialized, getLanguageByLangcode]) useEffect(() => { fetchResult() @@ -213,16 +223,88 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { const isPreparing = status === 'preparing' const isStreaming = status === 'streaming' - const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => { - if (!initialized) { - return - } - setTargetLanguage(targetLanguage) - targetLangRef.current = targetLanguage - setAlterLanguage(alterLanguage) + const handleChangeLanguage = useCallback( + (newTargetLanguage: TranslateLanguage, newAlterLanguage: TranslateLanguage) => { + if (!initialized) { + return + } + setTargetLanguage(newTargetLanguage) + targetLangRef.current = newTargetLanguage + setAlterLanguage(newAlterLanguage) - db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] }) - } + db.settings.put({ + id: 'translate:bidirectional:pair', + value: [newTargetLanguage.langCode, newAlterLanguage.langCode] + }) + }, + [initialized] + ) + + // Handle direct target language change from the main dropdown + const handleDirectTargetChange = useCallback( + (langCode: TranslateLanguageCode) => { + if (!initialized) return + const newLang = getLanguageByLangcode(langCode) + setActualTargetLanguage(newLang) + + // Update settings: if new target equals current target, keep as is + // Otherwise, swap if needed or just update target + if (newLang.langCode !== targetLanguage.langCode && newLang.langCode !== alterLanguage.langCode) { + // New language is different from both, update target + setTargetLanguage(newLang) + targetLangRef.current = newLang + db.settings.put({ id: 'translate:bidirectional:pair', value: [newLang.langCode, alterLanguage.langCode] }) + } + }, + [initialized, getLanguageByLangcode, targetLanguage.langCode, alterLanguage.langCode] + ) + + // Settings dropdown menu items + const settingsMenuItems = useMemo( + () => [ + { + key: 'preferred', + label: ( + + {t('translate.preferred_target')} + e.stopPropagation()} + onChange={(value) => { + handleChangeLanguage(getLanguageByLangcode(value), alterLanguage) + setSettingsOpen(false) + }} + disabled={isStreaming} + /> + + ) + }, + { + key: 'alter', + label: ( + + {t('translate.alter_language')} + e.stopPropagation()} + onChange={(value) => { + handleChangeLanguage(targetLanguage, getLanguageByLangcode(value)) + setSettingsOpen(false) + }} + disabled={isStreaming} + /> + + ) + } + ], + [t, targetLanguage, alterLanguage, isStreaming, getLanguageByLangcode, handleChangeLanguage] + ) const handlePause = () => { // FIXME: It doesn't work because abort signal is not set. @@ -242,39 +324,58 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { return ( <> + - - - - - + + {/* Detected language display (read-only) */} + + {isPreparing ? ( + {t('translate.detecting')} + ) : ( + <> + {detectedLanguage?.emoji || '🌐'} + {detectedLanguage?.label() || t('translate.detected_source')} + + )} + + + + + {/* Target language selector */} handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)} + onChange={handleDirectTargetChange} disabled={isStreaming} /> - - - - handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))} - disabled={isStreaming} - /> - - - - - + + {/* Settings dropdown */} + + + + + + + + + + + + + setShowOriginal(!showOriginal)}> {showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')} @@ -390,12 +491,72 @@ const ErrorMsg = styled.div` word-break: break-all; ` -const Spacer = styled.div` - flex-grow: 0.5; +const LeftGroup = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 1; + min-width: 0; ` -const QuestionIcon = styled(CircleHelp)` + +const DetectedLanguageTag = styled.div` + display: flex; + align-items: center; + padding: 4px 8px; + background-color: var(--color-background-soft); + border-radius: 4px; + font-size: 12px; + color: var(--color-text-secondary); + white-space: nowrap; + flex-shrink: 0; +` + +const SettingsButton = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; cursor: pointer; color: var(--color-text-3); + flex-shrink: 0; + + &:hover { + background-color: var(--color-background-soft); + color: var(--color-text); + } +` + +const SettingsMenuItem = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 0; + min-width: 180px; + cursor: default; +` + +const SettingsLabel = styled.span` + font-size: 12px; + color: var(--color-text-secondary); +` + +const HelpIcon = styled(CircleHelp)` + cursor: pointer; + color: var(--color-text-3); + flex-shrink: 0; +` + +const SettingsDropdownStyles = createGlobalStyle` + .settings-dropdown-menu { + .ant-dropdown-menu-item { + cursor: default !important; + &:hover { + background-color: transparent !important; + } + } + } ` export default ActionTranslate