From 0df331cf8a746a1ebfce6432f0ae4d7a7dd870f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Fri, 30 May 2025 13:49:39 +0800 Subject: [PATCH] feat: improve translation setting logic (#6463) * feat: add auto-detect language option and improve translation logic * feat: remove auto-detect language option and add bidirectional translation settings * fix: remove unused model removal function from TranslatePage component * feat: add language detection and bidirectional translation utilities * feat: update translation settings to include bidirectional translation tips and remove deprecated options * fix: improve interaction * fix: change cld3-asm to franc * fix: ui/ux * fix: change eslint * fix: update * Revert "fix: update" This reverts commit 1126a5cce936e470f2bde04cfc93d6f64e4b94ab. * Reapply "fix: update" This reverts commit 82b7890f92571a2b5ab470cdc365a36628a3013c. * fix: setloading missing --- package.json | 1 + src/renderer/src/i18n/locales/en-us.json | 13 +- src/renderer/src/i18n/locales/ja-jp.json | 15 +- src/renderer/src/i18n/locales/ru-ru.json | 15 +- src/renderer/src/i18n/locales/zh-cn.json | 15 +- src/renderer/src/i18n/locales/zh-tw.json | 17 +- .../src/pages/translate/TranslatePage.tsx | 442 +++++++++++++----- src/renderer/src/utils/translate.ts | 216 +++++++++ yarn.lock | 34 ++ 9 files changed, 648 insertions(+), 120 deletions(-) create mode 100644 src/renderer/src/utils/translate.ts diff --git a/package.json b/package.json index f6f6c07c15..b746f0538f 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "electron-window-state": "^5.0.3", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", "fast-xml-parser": "^5.2.0", + "franc": "^6.2.0", "fs-extra": "^11.2.0", "jsdom": "^26.0.0", "markdown-it": "^14.1.0", diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c30d4a59f7..0103dd54b7 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1798,6 +1798,7 @@ "close": "Close", "closed": "Translation closed", "copied": "Translation content copied", + "detected.language": "Detected Language", "empty": "Translation content is empty", "not.found": "Translation content not found", "confirm": { @@ -1816,8 +1817,16 @@ "input.placeholder": "Enter text to translate", "output.placeholder": "Translation", "processing": "Translation in progress...", - "scroll_sync.disable": "Disable synced scroll", - "scroll_sync.enable": "Enable synced scroll", + "language.same": "Source and target languages are the same", + "language.not_pair": "Source language is different from the set language", + "settings": { + "title": "Translation Settings", + "model": "Model Settings", + "model_desc": "Model used for translation service", + "bidirectional": "Bidirectional Translation Settings", + "bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported", + "scroll_sync": "Scroll Sync Settings" + }, "title": "Translation", "tooltip.newline": "Newline", "menu": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 70735c169f..9deea01c9c 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1815,13 +1815,22 @@ "input.placeholder": "翻訳するテキストを入力", "output.placeholder": "翻訳", "processing": "翻訳中...", - "scroll_sync.disable": "關閉滾動同步", - "scroll_sync.enable": "開啟滾動同步", + "language.same": "ソース言語と目標言語が同じです", + "language.not_pair": "ソース言語が設定された言語と異なります", + "settings": { + "title": "翻訳設定", + "model": "モデル設定", + "model_desc": "翻訳サービスで使用されるモデル", + "bidirectional": "双方向翻訳設定", + "bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます", + "scroll_sync": "スクロール同期設定" + }, "title": "翻訳", "tooltip.newline": "改行", "menu": { "description": "對當前輸入框內容進行翻譯" - } + }, + "detected.language": "検出された言語" }, "tray": { "quit": "終了", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index eb7320dbbd..c21074200a 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1815,13 +1815,22 @@ "input.placeholder": "Введите текст для перевода", "output.placeholder": "Перевод", "processing": "Перевод в процессе...", - "scroll_sync.disable": "Отключить синхронизацию прокрутки", - "scroll_sync.enable": "Включить синхронизацию прокрутки", + "language.same": "Исходный и целевой языки совпадают", + "language.not_pair": "Исходный язык отличается от настроенного", + "settings": { + "title": "Настройки перевода", + "model": "Настройки модели", + "model_desc": "Модель, используемая для службы перевода", + "bidirectional": "Настройки двунаправленного перевода", + "scroll_sync": "Настройки синхронизации прокрутки", + "bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот." + }, "title": "Перевод", "tooltip.newline": "Перевести", "menu": { "description": "Перевести содержимое текущего ввода" - } + }, + "detected.language": "Обнаруженный язык" }, "tray": { "quit": "Выйти", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2a819e40f7..ce3c764678 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1818,10 +1818,19 @@ "input.placeholder": "输入文本进行翻译", "output.placeholder": "翻译", "processing": "翻译中...", - "scroll_sync.disable": "关闭滚动同步", - "scroll_sync.enable": "开启滚动同步", + "language.same": "源语言和目标语言相同", + "language.not_pair": "源语言与设置的语言不同", + "settings": { + "title": "翻译设置", + "model": "模型设置", + "model_desc": "翻译服务使用的模型", + "bidirectional": "双向翻译设置", + "bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译", + "scroll_sync": "滚动同步设置" + }, "title": "翻译", - "tooltip.newline": "换行" + "tooltip.newline": "换行", + "detected.language": "检测到的语言" }, "tray": { "quit": "退出", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a811d334b0..b6f4a97d4e 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -615,7 +615,7 @@ "citations": "引用內容", "copied": "已複製!", "copy.failed": "複製失敗", - "copy.success": "已複製!", + "copy.success": "複製成功", "delete.confirm.title": "刪除確認", "delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?", "delete.failed": "刪除失敗", @@ -1815,13 +1815,22 @@ "input.placeholder": "輸入文字進行翻譯", "output.placeholder": "翻譯", "processing": "翻譯中...", - "scroll_sync.disable": "關閉滾動同步", - "scroll_sync.enable": "開啟滾動同步", + "language.same": "源語言和目標語言相同", + "language.not_pair": "源語言與設定的語言不同", + "settings": { + "title": "翻譯設定", + "model": "模型設定", + "model_desc": "翻譯服務使用的模型", + "bidirectional": "雙向翻譯設定", + "bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯", + "scroll_sync": "滾動同步設定" + }, "title": "翻譯", "tooltip.newline": "換行", "menu": { "description": "對當前輸入框內容進行翻譯" - } + }, + "detected.language": "檢測到的語言" }, "tray": { "quit": "結束", diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 0a29238eb7..7653aba898 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -1,50 +1,256 @@ import { CheckOutlined, DeleteOutlined, HistoryOutlined, SendOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import CopyIcon from '@renderer/components/Icons/CopyIcon' -import { isLocalAi } from '@renderer/config/env' +import { HStack } from '@renderer/components/Layout' +import { isEmbeddingModel } from '@renderer/config/models' import { translateLanguageOptions } from '@renderer/config/translate' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' +import { useProviders } from '@renderer/hooks/useProvider' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' -import type { Assistant, TranslateHistory } from '@renderer/types' +import { getModelUniqId, hasModel } from '@renderer/services/ModelService' +import type { Model, TranslateHistory } from '@renderer/types' import { runAsyncFunction, uuid } from '@renderer/utils' -import { Button, Dropdown, Empty, Flex, Popconfirm, Select, Space, Tooltip } from 'antd' +import { + createInputScrollHandler, + createOutputScrollHandler, + detectLanguage, + determineTargetLanguage +} from '@renderer/utils/translate' +import { Button, Dropdown, Empty, Flex, Modal, Popconfirm, Select, Space, Switch, Tooltip } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import dayjs from 'dayjs' import { useLiveQuery } from 'dexie-react-hooks' -import { isEmpty } from 'lodash' -import { Mouse, Settings2, TriangleAlert } from 'lucide-react' -import { FC, useEffect, useRef, useState } from 'react' +import { find, isEmpty, sortBy } from 'lodash' +import { HelpCircle, Settings2, TriangleAlert } from 'lucide-react' +import { FC, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' import styled from 'styled-components' let _text = '' let _result = '' let _targetLanguage = 'english' +const TranslateSettings: FC<{ + visible: boolean + onClose: () => void + isScrollSyncEnabled: boolean + setIsScrollSyncEnabled: (value: boolean) => void + isBidirectional: boolean + setIsBidirectional: (value: boolean) => void + bidirectionalPair: [string, string] + setBidirectionalPair: (value: [string, string]) => void + translateModel: Model | undefined + onModelChange: (model: Model) => void + allModels: Model[] + selectOptions: any[] +}> = ({ + visible, + onClose, + isScrollSyncEnabled, + setIsScrollSyncEnabled, + isBidirectional, + setIsBidirectional, + bidirectionalPair, + setBidirectionalPair, + translateModel, + onModelChange, + allModels, + selectOptions +}) => { + const { t } = useTranslation() + const [localPair, setLocalPair] = useState<[string, string]>(bidirectionalPair) + + const defaultTranslateModel = useMemo( + () => (hasModel(translateModel) ? getModelUniqId(translateModel) : undefined), + [translateModel] + ) + + useEffect(() => { + setLocalPair(bidirectionalPair) + }, [bidirectionalPair, visible]) + + const handleSave = () => { + if (localPair[0] === localPair[1]) { + window.message.warning({ + content: t('translate.language.same'), + key: 'translate-message' + }) + return + } + setBidirectionalPair(localPair) + db.settings.put({ id: 'translate:bidirectional:pair', value: localPair }) + db.settings.put({ id: 'translate:scroll:sync', value: isScrollSyncEnabled }) + window.message.success({ + content: t('message.save.success.title'), + key: 'translate-settings-save' + }) + onClose() + } + + return ( + {t('translate.settings.title')}} + open={visible} + onCancel={onClose} + centered={true} + footer={[ + + {t('common.cancel')} + , + + {t('common.save')} + + ]} + width={420}> + + + {t('translate.settings.model')} + + { + const selectedModel = find(allModels, JSON.parse(value)) as Model + if (selectedModel) { + onModelChange(selectedModel) + } + }} + options={selectOptions} + showSearch + /> + + {!translateModel && ( + + + + {t('translate.settings.no_model_warning')} + + + )} + + {t('translate.settings.model_desc')} + + + + + + {t('translate.settings.scroll_sync')} + + + + + + + + + {t('translate.settings.bidirectional')} + + + + + + + + + + + {isBidirectional && ( + + setLocalPair([value, localPair[1]])} + options={translateLanguageOptions().map((lang) => ({ + value: lang.value, + label: ( + + + {lang.emoji} + + {lang.label} + + ) + }))} + /> + ⇆ + setLocalPair([localPair[0], value])} + options={translateLanguageOptions().map((lang) => ({ + value: lang.value, + label: ( + + + {lang.emoji} + + {lang.label} + + ) + }))} + /> + + )} + + + + + ) +} + const TranslatePage: FC = () => { const { t } = useTranslation() const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const [text, setText] = useState(_text) const [result, setResult] = useState(_result) - const { translateModel } = useDefaultModel() + const { translateModel, setTranslateModel } = useDefaultModel() const [loading, setLoading] = useState(false) const [copied, setCopied] = useState(false) const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false) const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false) + const [isBidirectional, setIsBidirectional] = useState(false) + const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese']) + const [settingsVisible, setSettingsVisible] = useState(false) + const [detectedLanguage, setDetectedLanguage] = useState(null) const contentContainerRef = useRef(null) const textAreaRef = useRef(null) const outputTextRef = useRef(null) const isProgrammaticScroll = useRef(false) + const { providers } = useProviders() + const allModels = useMemo(() => providers.map((p) => p.models).flat(), [providers]) + const translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), []) _text = text _result = result _targetLanguage = targetLanguage + const selectOptions = useMemo( + () => + providers + .filter((p) => p.models.length > 0) + .map((p) => ({ + label: p.isSystem ? t(`provider.${p.id}`) : p.name, + title: p.name, + options: sortBy(p.models, 'name') + .filter((m) => !isEmbeddingModel(m)) + .map((m) => ({ + label: `${m.name} | ${p.isSystem ? t(`provider.${p.id}`) : p.name}`, + value: getModelUniqId(m) + })) + })), + [providers, t] + ) + + const handleModelChange = (model: Model) => { + setTranslateModel(model) + db.settings.put({ id: 'translate:model', value: model.id }) + } + const saveTranslateHistory = async ( sourceText: string, targetText: string, @@ -71,10 +277,7 @@ const TranslatePage: FC = () => { } const onTranslate = async () => { - if (!text.trim()) { - return - } - + if (!text.trim()) return if (!translateModel) { window.message.error({ content: t('translate.error.not_configured'), @@ -83,11 +286,35 @@ const TranslatePage: FC = () => { return } - const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text) - setLoading(true) - let translatedText = '' try { + const sourceLanguage = await detectLanguage(text) + console.log('检测到的语言:', sourceLanguage) + setDetectedLanguage(sourceLanguage) + const result = determineTargetLanguage(sourceLanguage, targetLanguage, isBidirectional, bidirectionalPair) + if (!result.success) { + let errorMessage = '' + if (result.errorType === 'same_language') { + errorMessage = t('translate.language.same') + } else if (result.errorType === 'not_in_pair') { + errorMessage = t('translate.language.not_pair') + } + + window.message.warning({ + content: errorMessage, + key: 'translate-message' + }) + setLoading(false) + return + } + + const actualTargetLanguage = result.language as string + if (isBidirectional) { + setTargetLanguage(actualTargetLanguage) + } + + const assistant = getDefaultTranslateAssistant(actualTargetLanguage, text) + let translatedText = '' await fetchTranslate({ content: text, assistant, @@ -96,6 +323,9 @@ const TranslatePage: FC = () => { setResult(translatedText) } }) + + await saveTranslateHistory(text, translatedText, sourceLanguage, actualTargetLanguage) + setLoading(false) } catch (error) { console.error('Translation error:', error) window.message.error({ @@ -105,9 +335,11 @@ const TranslatePage: FC = () => { setLoading(false) return } + } - await saveTranslateHistory(text, translatedText, 'any', targetLanguage) - setLoading(false) + const toggleBidirectional = (value: boolean) => { + setIsBidirectional(value) + db.settings.put({ id: 'translate:bidirectional:enabled', value }) } const onCopy = () => { @@ -130,6 +362,24 @@ const TranslatePage: FC = () => { runAsyncFunction(async () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) targetLang && setTargetLanguage(targetLang.value) + + const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' }) + if (bidirectionalPairSetting) { + const langPair = bidirectionalPairSetting.value + if (Array.isArray(langPair) && langPair.length === 2 && langPair[0] !== langPair[1]) { + setBidirectionalPair(langPair as [string, string]) + } else { + const defaultPair: [string, string] = ['english', 'chinese'] + setBidirectionalPair(defaultPair) + db.settings.put({ id: 'translate:bidirectional:pair', value: defaultPair }) + } + } + + const bidirectionalSetting = await db.settings.get({ id: 'translate:bidirectional:enabled' }) + setIsBidirectional(bidirectionalSetting ? bidirectionalSetting.value : false) + + const scrollSyncSetting = await db.settings.get({ id: 'translate:scroll:sync' }) + setIsScrollSyncEnabled(scrollSyncSetting ? scrollSyncSetting.value : false) }) }, []) @@ -141,71 +391,44 @@ const TranslatePage: FC = () => { } } - const SettingButton = () => { - if (isLocalAi) { - return null - } + const handleInputScroll = createInputScrollHandler(outputTextRef, isProgrammaticScroll, isScrollSyncEnabled) + const handleOutputScroll = createOutputScrollHandler(textAreaRef, isProgrammaticScroll, isScrollSyncEnabled) - if (translateModel) { + // 获取当前语言状态显示 + const getLanguageDisplay = () => { + if (isBidirectional) { return ( - - - + + + {`${t(`languages.${bidirectionalPair[0]}`)} ⇆ ${t(`languages.${bidirectionalPair[1]}`)}`} + + ) } return ( - - }> - {t('translate.error.not_configured')} - - + { + setTargetLanguage(value) + db.settings.put({ id: 'translate:target:language', value }) + }} + options={translateLanguageOptions().map((lang) => ({ + value: lang.value, + label: ( + + + {lang.emoji} + + {lang.label} + + ) + }))} + /> ) } - // Handle input area scroll event - const handleInputScroll = (e: React.UIEvent) => { - if (!isScrollSyncEnabled || !outputTextRef.current || isProgrammaticScroll.current) return - - isProgrammaticScroll.current = true - - const inputEl = e.currentTarget - const outputEl = outputTextRef.current - - // Calculate scroll position by ratio - const inputScrollRatio = inputEl.scrollTop / (inputEl.scrollHeight - inputEl.clientHeight || 1) - outputEl.scrollTop = inputScrollRatio * (outputEl.scrollHeight - outputEl.clientHeight || 1) - - requestAnimationFrame(() => { - isProgrammaticScroll.current = false - }) - } - - // Handle output area scroll event - const handleOutputScroll = (e: React.UIEvent) => { - const inputEl = textAreaRef.current?.resizableTextArea?.textArea - if (!isScrollSyncEnabled || !inputEl || isProgrammaticScroll.current) return - - isProgrammaticScroll.current = true - - const outputEl = e.currentTarget - - // Calculate scroll position by ratio - const outputScrollRatio = outputEl.scrollTop / (outputEl.scrollHeight - outputEl.clientHeight || 1) - inputEl.scrollTop = outputScrollRatio * (inputEl.scrollHeight - inputEl.clientHeight || 1) - - requestAnimationFrame(() => { - isProgrammaticScroll.current = false - }) - } - - const toggleScrollSync = () => { - setIsScrollSyncEnabled(!isScrollSyncEnabled) - } - return ( @@ -275,23 +498,23 @@ const TranslatePage: FC = () => { + } + onClick={() => setSettingsVisible(true)} + style={{ color: 'var(--color-text-2)', display: 'flex' }} /> - - - - { - { - setTargetLanguage(value) - db.settings.put({ id: 'translate:target:language', value }) - }} - optionRender={(option) => ( - - - {option.data.emoji} - - {option.label} - - )} - /> + + {getLanguageDisplay()} + { + + setSettingsVisible(false)} + isScrollSyncEnabled={isScrollSyncEnabled} + setIsScrollSyncEnabled={setIsScrollSyncEnabled} + isBidirectional={isBidirectional} + setIsBidirectional={toggleBidirectional} + bidirectionalPair={bidirectionalPair} + setBidirectionalPair={setBidirectionalPair} + translateModel={translateModel} + onModelChange={handleModelChange} + allModels={allModels} + selectOptions={selectOptions} + /> ) } @@ -437,6 +659,16 @@ const TranslateButton = styled(Button)`` const CopyButton = styled(Button)`` +const BidirectionalLanguageDisplay = styled.div` + padding: 4px 11px; + border-radius: 6px; + background-color: var(--color-background); + border: 1px solid var(--color-border); + font-size: 14px; + width: 100%; + text-align: center; +` + const HistoryContainner = styled.div<{ $historyDrawerVisible: boolean }>` width: ${({ $historyDrawerVisible }) => ($historyDrawerVisible ? '300px' : '0')}; height: calc(100vh - var(--navbar-height) - 40px); diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts new file mode 100644 index 0000000000..3874af0181 --- /dev/null +++ b/src/renderer/src/utils/translate.ts @@ -0,0 +1,216 @@ +import { franc } from 'franc' +import React, { MutableRefObject } from 'react' + +/** + * 使用Unicode字符范围检测语言 + * 适用于较短文本的语言检测 + * @param {string} text 需要检测语言的文本 + * @returns {string} 检测到的语言代码 + */ +export const detectLanguageByUnicode = (text: string): string => { + const counts = { + zh: 0, + ja: 0, + ko: 0, + ru: 0, + ar: 0, + latin: 0 + } + + let totalChars = 0 + + for (const char of text) { + const code = char.codePointAt(0) || 0 + totalChars++ + + if (code >= 0x4e00 && code <= 0x9fff) { + counts.zh++ + } else if ((code >= 0x3040 && code <= 0x309f) || (code >= 0x30a0 && code <= 0x30ff)) { + counts.ja++ + } else if ((code >= 0xac00 && code <= 0xd7a3) || (code >= 0x1100 && code <= 0x11ff)) { + counts.ko++ + } else if (code >= 0x0400 && code <= 0x04ff) { + counts.ru++ + } else if (code >= 0x0600 && code <= 0x06ff) { + counts.ar++ + } else if ((code >= 0x0020 && code <= 0x007f) || (code >= 0x0080 && code <= 0x00ff)) { + counts.latin++ + } else { + totalChars-- + } + } + + if (totalChars === 0) return 'en' + let maxLang = 'en' + let maxCount = 0 + + for (const [lang, count] of Object.entries(counts)) { + if (count > maxCount) { + maxCount = count + maxLang = lang === 'latin' ? 'en' : lang + } + } + + if (maxCount / totalChars < 0.3) { + return 'en' + } + return maxLang +} + +/** + * 检测输入文本的语言 + * @param {string} inputText 需要检测语言的文本 + * @returns {Promise} 检测到的语言代码 + */ +export const detectLanguage = async (inputText: string): Promise => { + const text = inputText.trim() + if (!text) return 'any' + let code: string + + // 如果文本长度小于20个字符,使用Unicode范围检测 + if (text.length < 20) { + code = detectLanguageByUnicode(text) + } else { + // franc 返回 ISO 639-3 代码 + const iso3 = franc(text) + const isoMap: Record = { + cmn: 'zh', + jpn: 'ja', + kor: 'ko', + rus: 'ru', + ara: 'ar', + spa: 'es', + fra: 'fr', + deu: 'de', + ita: 'it', + por: 'pt', + eng: 'en' + } + code = isoMap[iso3] || 'en' + } + + // 映射到应用使用的语言键 + const languageMap: Record = { + zh: 'chinese', + ja: 'japanese', + ko: 'korean', + ru: 'russian', + es: 'spanish', + fr: 'french', + de: 'german', + it: 'italian', + pt: 'portuguese', + ar: 'arabic', + en: 'english' + } + + return languageMap[code] || 'english' +} + +/** + * 获取双向翻译的目标语言 + * @param sourceLanguage 检测到的源语言 + * @param languagePair 配置的语言对 + * @returns 目标语言 + */ +export const getTargetLanguageForBidirectional = (sourceLanguage: string, languagePair: [string, string]): string => { + if (sourceLanguage === languagePair[0]) { + return languagePair[1] + } else if (sourceLanguage === languagePair[1]) { + return languagePair[0] + } + return languagePair[0] !== sourceLanguage ? languagePair[0] : languagePair[1] +} + +/** + * 检查源语言是否在配置的语言对中 + * @param sourceLanguage 检测到的源语言 + * @param languagePair 配置的语言对 + * @returns 是否在语言对中 + */ +export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, string]): boolean => { + return [languagePair[0], languagePair[1]].includes(sourceLanguage) +} + +/** + * 确定翻译的目标语言 + * @param sourceLanguage 检测到的源语言 + * @param targetLanguage 用户设置的目标语言 + * @param isBidirectional 是否开启双向翻译 + * @param bidirectionalPair 双向翻译的语言对 + * @returns 处理结果对象 + */ +export const determineTargetLanguage = ( + sourceLanguage: string, + targetLanguage: string, + isBidirectional: boolean, + bidirectionalPair: [string, string] +): { success: boolean; language?: string; errorType?: 'same_language' | 'not_in_pair' } => { + if (isBidirectional) { + if (!isLanguageInPair(sourceLanguage, bidirectionalPair)) { + return { success: false, errorType: 'not_in_pair' } + } + return { + success: true, + language: getTargetLanguageForBidirectional(sourceLanguage, bidirectionalPair) + } + } else { + if (sourceLanguage === targetLanguage) { + return { success: false, errorType: 'same_language' } + } + return { success: true, language: targetLanguage } + } +} + +/** + * 处理滚动同步 + * @param sourceElement 源元素 + * @param targetElement 目标元素 + * @param isProgrammaticScrollRef 是否程序控制滚动的引用 + */ +export const handleScrollSync = ( + sourceElement: HTMLElement, + targetElement: HTMLElement, + isProgrammaticScrollRef: MutableRefObject +): void => { + if (isProgrammaticScrollRef.current) return + + isProgrammaticScrollRef.current = true + + // 计算滚动位置比例 + const scrollRatio = sourceElement.scrollTop / (sourceElement.scrollHeight - sourceElement.clientHeight || 1) + targetElement.scrollTop = scrollRatio * (targetElement.scrollHeight - targetElement.clientHeight || 1) + + requestAnimationFrame(() => { + isProgrammaticScrollRef.current = false + }) +} + +/** + * 创建输入区域滚动处理函数 + */ +export const createInputScrollHandler = ( + targetRef: MutableRefObject, + isProgrammaticScrollRef: MutableRefObject, + isScrollSyncEnabled: boolean +) => { + return (e: React.UIEvent) => { + if (!isScrollSyncEnabled || !targetRef.current || isProgrammaticScrollRef.current) return + handleScrollSync(e.currentTarget, targetRef.current, isProgrammaticScrollRef) + } +} + +/** + * 创建输出区域滚动处理函数 + */ +export const createOutputScrollHandler = ( + textAreaRef: MutableRefObject, + isProgrammaticScrollRef: MutableRefObject, + isScrollSyncEnabled: boolean +) => { + return (e: React.UIEvent) => { + const inputEl = textAreaRef.current?.resizableTextArea?.textArea + if (!isScrollSyncEnabled || !inputEl || isProgrammaticScrollRef.current) return + handleScrollSync(e.currentTarget, inputEl, isProgrammaticScrollRef) + } +} diff --git a/yarn.lock b/yarn.lock index 92bd8ef6fc..4739c64373 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5621,6 +5621,7 @@ __metadata: eslint-plugin-unused-imports: "npm:^4.1.4" fast-diff: "npm:^1.3.0" fast-xml-parser: "npm:^5.2.0" + franc: "npm:^6.2.0" fs-extra: "npm:^11.2.0" html-to-image: "npm:^1.11.13" husky: "npm:^9.1.7" @@ -6953,6 +6954,13 @@ __metadata: languageName: node linkType: hard +"collapse-white-space@npm:^2.0.0": + version: 2.1.0 + resolution: "collapse-white-space@npm:2.1.0" + checksum: 10c0/b2e2800f4ab261e62eb27a1fbe853378296e3a726d6695117ed033e82d61fb6abeae4ffc1465d5454499e237005de9cfc52c9562dc7ca4ac759b9a222ef14453 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -9812,6 +9820,15 @@ __metadata: languageName: node linkType: hard +"franc@npm:^6.2.0": + version: 6.2.0 + resolution: "franc@npm:6.2.0" + dependencies: + trigram-utils: "npm:^2.0.0" + checksum: 10c0/136a08d6e4632f17eae6f0ae93b224b0bf2233dc1d5dbd0b23e479960f6c71c0847bef834d3b6b7c9cefb4f905d5e08fc82b0738bb3ed4a6c83faffcf9fa2a11 + languageName: node + linkType: hard + "fresh@npm:^2.0.0": version: 2.0.0 resolution: "fresh@npm:2.0.0" @@ -13543,6 +13560,13 @@ __metadata: languageName: node linkType: hard +"n-gram@npm:^2.0.0": + version: 2.0.2 + resolution: "n-gram@npm:2.0.2" + checksum: 10c0/72e2cdc8c37c9253b556a0deb9cd26d5ac59a5d7a38b2d2928927e3959bc7d3cb591d766e30309a4c685dbc51330025cb30c5c6518ee516caf3318aed2635f1b + languageName: node + linkType: hard + "nanoid@npm:^3.3.7, nanoid@npm:^3.3.8": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -17509,6 +17533,16 @@ __metadata: languageName: node linkType: hard +"trigram-utils@npm:^2.0.0": + version: 2.0.1 + resolution: "trigram-utils@npm:2.0.1" + dependencies: + collapse-white-space: "npm:^2.0.0" + n-gram: "npm:^2.0.0" + checksum: 10c0/d024dc91a9c0310e75fa68422185e3a32814831971b9e86a2925e74bd1932a30501aa2ac214768f0a545f3db63610ee14b4748ac31532e1bc46c791941d71c6d + languageName: node + linkType: hard + "trim-lines@npm:^3.0.0": version: 3.0.1 resolution: "trim-lines@npm:3.0.1"