mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 13:59:28 +08:00
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 commit1126a5cce9. * Reapply "fix: update" This reverts commit82b7890f92. * fix: setloading missing
This commit is contained in:
parent
a5a04e1df7
commit
0df331cf8a
@ -83,6 +83,7 @@
|
|||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||||
"fast-xml-parser": "^5.2.0",
|
"fast-xml-parser": "^5.2.0",
|
||||||
|
"franc": "^6.2.0",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
|||||||
@ -1798,6 +1798,7 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"closed": "Translation closed",
|
"closed": "Translation closed",
|
||||||
"copied": "Translation content copied",
|
"copied": "Translation content copied",
|
||||||
|
"detected.language": "Detected Language",
|
||||||
"empty": "Translation content is empty",
|
"empty": "Translation content is empty",
|
||||||
"not.found": "Translation content not found",
|
"not.found": "Translation content not found",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
@ -1816,8 +1817,16 @@
|
|||||||
"input.placeholder": "Enter text to translate",
|
"input.placeholder": "Enter text to translate",
|
||||||
"output.placeholder": "Translation",
|
"output.placeholder": "Translation",
|
||||||
"processing": "Translation in progress...",
|
"processing": "Translation in progress...",
|
||||||
"scroll_sync.disable": "Disable synced scroll",
|
"language.same": "Source and target languages are the same",
|
||||||
"scroll_sync.enable": "Enable synced scroll",
|
"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",
|
"title": "Translation",
|
||||||
"tooltip.newline": "Newline",
|
"tooltip.newline": "Newline",
|
||||||
"menu": {
|
"menu": {
|
||||||
|
|||||||
@ -1815,13 +1815,22 @@
|
|||||||
"input.placeholder": "翻訳するテキストを入力",
|
"input.placeholder": "翻訳するテキストを入力",
|
||||||
"output.placeholder": "翻訳",
|
"output.placeholder": "翻訳",
|
||||||
"processing": "翻訳中...",
|
"processing": "翻訳中...",
|
||||||
"scroll_sync.disable": "關閉滾動同步",
|
"language.same": "ソース言語と目標言語が同じです",
|
||||||
"scroll_sync.enable": "開啟滾動同步",
|
"language.not_pair": "ソース言語が設定された言語と異なります",
|
||||||
|
"settings": {
|
||||||
|
"title": "翻訳設定",
|
||||||
|
"model": "モデル設定",
|
||||||
|
"model_desc": "翻訳サービスで使用されるモデル",
|
||||||
|
"bidirectional": "双方向翻訳設定",
|
||||||
|
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
|
||||||
|
"scroll_sync": "スクロール同期設定"
|
||||||
|
},
|
||||||
"title": "翻訳",
|
"title": "翻訳",
|
||||||
"tooltip.newline": "改行",
|
"tooltip.newline": "改行",
|
||||||
"menu": {
|
"menu": {
|
||||||
"description": "對當前輸入框內容進行翻譯"
|
"description": "對當前輸入框內容進行翻譯"
|
||||||
}
|
},
|
||||||
|
"detected.language": "検出された言語"
|
||||||
},
|
},
|
||||||
"tray": {
|
"tray": {
|
||||||
"quit": "終了",
|
"quit": "終了",
|
||||||
|
|||||||
@ -1815,13 +1815,22 @@
|
|||||||
"input.placeholder": "Введите текст для перевода",
|
"input.placeholder": "Введите текст для перевода",
|
||||||
"output.placeholder": "Перевод",
|
"output.placeholder": "Перевод",
|
||||||
"processing": "Перевод в процессе...",
|
"processing": "Перевод в процессе...",
|
||||||
"scroll_sync.disable": "Отключить синхронизацию прокрутки",
|
"language.same": "Исходный и целевой языки совпадают",
|
||||||
"scroll_sync.enable": "Включить синхронизацию прокрутки",
|
"language.not_pair": "Исходный язык отличается от настроенного",
|
||||||
|
"settings": {
|
||||||
|
"title": "Настройки перевода",
|
||||||
|
"model": "Настройки модели",
|
||||||
|
"model_desc": "Модель, используемая для службы перевода",
|
||||||
|
"bidirectional": "Настройки двунаправленного перевода",
|
||||||
|
"scroll_sync": "Настройки синхронизации прокрутки",
|
||||||
|
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
|
||||||
|
},
|
||||||
"title": "Перевод",
|
"title": "Перевод",
|
||||||
"tooltip.newline": "Перевести",
|
"tooltip.newline": "Перевести",
|
||||||
"menu": {
|
"menu": {
|
||||||
"description": "Перевести содержимое текущего ввода"
|
"description": "Перевести содержимое текущего ввода"
|
||||||
}
|
},
|
||||||
|
"detected.language": "Обнаруженный язык"
|
||||||
},
|
},
|
||||||
"tray": {
|
"tray": {
|
||||||
"quit": "Выйти",
|
"quit": "Выйти",
|
||||||
|
|||||||
@ -1818,10 +1818,19 @@
|
|||||||
"input.placeholder": "输入文本进行翻译",
|
"input.placeholder": "输入文本进行翻译",
|
||||||
"output.placeholder": "翻译",
|
"output.placeholder": "翻译",
|
||||||
"processing": "翻译中...",
|
"processing": "翻译中...",
|
||||||
"scroll_sync.disable": "关闭滚动同步",
|
"language.same": "源语言和目标语言相同",
|
||||||
"scroll_sync.enable": "开启滚动同步",
|
"language.not_pair": "源语言与设置的语言不同",
|
||||||
|
"settings": {
|
||||||
|
"title": "翻译设置",
|
||||||
|
"model": "模型设置",
|
||||||
|
"model_desc": "翻译服务使用的模型",
|
||||||
|
"bidirectional": "双向翻译设置",
|
||||||
|
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
|
||||||
|
"scroll_sync": "滚动同步设置"
|
||||||
|
},
|
||||||
"title": "翻译",
|
"title": "翻译",
|
||||||
"tooltip.newline": "换行"
|
"tooltip.newline": "换行",
|
||||||
|
"detected.language": "检测到的语言"
|
||||||
},
|
},
|
||||||
"tray": {
|
"tray": {
|
||||||
"quit": "退出",
|
"quit": "退出",
|
||||||
|
|||||||
@ -615,7 +615,7 @@
|
|||||||
"citations": "引用內容",
|
"citations": "引用內容",
|
||||||
"copied": "已複製!",
|
"copied": "已複製!",
|
||||||
"copy.failed": "複製失敗",
|
"copy.failed": "複製失敗",
|
||||||
"copy.success": "已複製!",
|
"copy.success": "複製成功",
|
||||||
"delete.confirm.title": "刪除確認",
|
"delete.confirm.title": "刪除確認",
|
||||||
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
|
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
|
||||||
"delete.failed": "刪除失敗",
|
"delete.failed": "刪除失敗",
|
||||||
@ -1815,13 +1815,22 @@
|
|||||||
"input.placeholder": "輸入文字進行翻譯",
|
"input.placeholder": "輸入文字進行翻譯",
|
||||||
"output.placeholder": "翻譯",
|
"output.placeholder": "翻譯",
|
||||||
"processing": "翻譯中...",
|
"processing": "翻譯中...",
|
||||||
"scroll_sync.disable": "關閉滾動同步",
|
"language.same": "源語言和目標語言相同",
|
||||||
"scroll_sync.enable": "開啟滾動同步",
|
"language.not_pair": "源語言與設定的語言不同",
|
||||||
|
"settings": {
|
||||||
|
"title": "翻譯設定",
|
||||||
|
"model": "模型設定",
|
||||||
|
"model_desc": "翻譯服務使用的模型",
|
||||||
|
"bidirectional": "雙向翻譯設定",
|
||||||
|
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
|
||||||
|
"scroll_sync": "滾動同步設定"
|
||||||
|
},
|
||||||
"title": "翻譯",
|
"title": "翻譯",
|
||||||
"tooltip.newline": "換行",
|
"tooltip.newline": "換行",
|
||||||
"menu": {
|
"menu": {
|
||||||
"description": "對當前輸入框內容進行翻譯"
|
"description": "對當前輸入框內容進行翻譯"
|
||||||
}
|
},
|
||||||
|
"detected.language": "檢測到的語言"
|
||||||
},
|
},
|
||||||
"tray": {
|
"tray": {
|
||||||
"quit": "結束",
|
"quit": "結束",
|
||||||
|
|||||||
@ -1,50 +1,256 @@
|
|||||||
import { CheckOutlined, DeleteOutlined, HistoryOutlined, SendOutlined } from '@ant-design/icons'
|
import { CheckOutlined, DeleteOutlined, HistoryOutlined, SendOutlined } from '@ant-design/icons'
|
||||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
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 { translateLanguageOptions } from '@renderer/config/translate'
|
||||||
import db from '@renderer/databases'
|
import db from '@renderer/databases'
|
||||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||||
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||||
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
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 { 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 TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { isEmpty } from 'lodash'
|
import { find, isEmpty, sortBy } from 'lodash'
|
||||||
import { Mouse, Settings2, TriangleAlert } from 'lucide-react'
|
import { HelpCircle, Settings2, TriangleAlert } from 'lucide-react'
|
||||||
import { FC, useEffect, useRef, useState } from 'react'
|
import { FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
let _text = ''
|
let _text = ''
|
||||||
let _result = ''
|
let _result = ''
|
||||||
let _targetLanguage = 'english'
|
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 (
|
||||||
|
<Modal
|
||||||
|
title={<div style={{ fontSize: 16 }}>{t('translate.settings.title')}</div>}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
centered={true}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>,
|
||||||
|
<Button key="save" type="primary" onClick={handleSave}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
width={420}>
|
||||||
|
<Flex vertical gap={16} style={{ marginTop: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('translate.settings.model')}</div>
|
||||||
|
<HStack alignItems="center" gap={5}>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={t('translate.settings.model_placeholder')}
|
||||||
|
value={defaultTranslateModel}
|
||||||
|
onChange={(value) => {
|
||||||
|
const selectedModel = find(allModels, JSON.parse(value)) as Model
|
||||||
|
if (selectedModel) {
|
||||||
|
onModelChange(selectedModel)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={selectOptions}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
{!translateModel && (
|
||||||
|
<div style={{ marginTop: 8, color: 'var(--color-warning)' }}>
|
||||||
|
<HStack alignItems="center" gap={5}>
|
||||||
|
<TriangleAlert size={14} />
|
||||||
|
<span style={{ fontSize: 12 }}>{t('translate.settings.no_model_warning')}</span>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-3)' }}>
|
||||||
|
{t('translate.settings.model_desc')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Flex align="center" justify="space-between">
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('translate.settings.scroll_sync')}</div>
|
||||||
|
<Switch checked={isScrollSyncEnabled} onChange={setIsScrollSyncEnabled} />
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Flex align="center" justify="space-between" style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>
|
||||||
|
<HStack alignItems="center" gap={5}>
|
||||||
|
{t('translate.settings.bidirectional')}
|
||||||
|
<Tooltip title={t('translate.settings.bidirectional_tip')}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<HelpCircle size={14} style={{ color: 'var(--color-text-3)' }} />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
<Switch checked={isBidirectional} onChange={setIsBidirectional} />
|
||||||
|
</Flex>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
{isBidirectional && (
|
||||||
|
<Flex align="center" justify="space-between" gap={10}>
|
||||||
|
<Select
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={localPair[0]}
|
||||||
|
onChange={(value) => setLocalPair([value, localPair[1]])}
|
||||||
|
options={translateLanguageOptions().map((lang) => ({
|
||||||
|
value: lang.value,
|
||||||
|
label: (
|
||||||
|
<Space.Compact direction="horizontal" block>
|
||||||
|
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||||
|
{lang.emoji}
|
||||||
|
</span>
|
||||||
|
<Space.Compact block>{lang.label}</Space.Compact>
|
||||||
|
</Space.Compact>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<span>⇆</span>
|
||||||
|
<Select
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={localPair[1]}
|
||||||
|
onChange={(value) => setLocalPair([localPair[0], value])}
|
||||||
|
options={translateLanguageOptions().map((lang) => ({
|
||||||
|
value: lang.value,
|
||||||
|
label: (
|
||||||
|
<Space.Compact direction="horizontal" block>
|
||||||
|
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||||
|
{lang.emoji}
|
||||||
|
</span>
|
||||||
|
<div style={{ textAlign: 'left', flex: 1 }}>{lang.label}</div>
|
||||||
|
</Space.Compact>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const TranslatePage: FC = () => {
|
const TranslatePage: FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
|
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
|
||||||
const [text, setText] = useState(_text)
|
const [text, setText] = useState(_text)
|
||||||
const [result, setResult] = useState(_result)
|
const [result, setResult] = useState(_result)
|
||||||
const { translateModel } = useDefaultModel()
|
const { translateModel, setTranslateModel } = useDefaultModel()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
|
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
|
||||||
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = 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<string | null>(null)
|
||||||
const contentContainerRef = useRef<HTMLDivElement>(null)
|
const contentContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const textAreaRef = useRef<TextAreaRef>(null)
|
const textAreaRef = useRef<TextAreaRef>(null)
|
||||||
const outputTextRef = useRef<HTMLDivElement>(null)
|
const outputTextRef = useRef<HTMLDivElement>(null)
|
||||||
const isProgrammaticScroll = useRef(false)
|
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(), [])
|
const translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), [])
|
||||||
|
|
||||||
_text = text
|
_text = text
|
||||||
_result = result
|
_result = result
|
||||||
_targetLanguage = targetLanguage
|
_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 (
|
const saveTranslateHistory = async (
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
targetText: string,
|
targetText: string,
|
||||||
@ -71,10 +277,7 @@ const TranslatePage: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onTranslate = async () => {
|
const onTranslate = async () => {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!translateModel) {
|
if (!translateModel) {
|
||||||
window.message.error({
|
window.message.error({
|
||||||
content: t('translate.error.not_configured'),
|
content: t('translate.error.not_configured'),
|
||||||
@ -83,11 +286,35 @@ const TranslatePage: FC = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text)
|
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
let translatedText = ''
|
|
||||||
try {
|
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({
|
await fetchTranslate({
|
||||||
content: text,
|
content: text,
|
||||||
assistant,
|
assistant,
|
||||||
@ -96,6 +323,9 @@ const TranslatePage: FC = () => {
|
|||||||
setResult(translatedText)
|
setResult(translatedText)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await saveTranslateHistory(text, translatedText, sourceLanguage, actualTargetLanguage)
|
||||||
|
setLoading(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Translation error:', error)
|
console.error('Translation error:', error)
|
||||||
window.message.error({
|
window.message.error({
|
||||||
@ -105,9 +335,11 @@ const TranslatePage: FC = () => {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await saveTranslateHistory(text, translatedText, 'any', targetLanguage)
|
const toggleBidirectional = (value: boolean) => {
|
||||||
setLoading(false)
|
setIsBidirectional(value)
|
||||||
|
db.settings.put({ id: 'translate:bidirectional:enabled', value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCopy = () => {
|
const onCopy = () => {
|
||||||
@ -130,6 +362,24 @@ const TranslatePage: FC = () => {
|
|||||||
runAsyncFunction(async () => {
|
runAsyncFunction(async () => {
|
||||||
const targetLang = await db.settings.get({ id: 'translate:target:language' })
|
const targetLang = await db.settings.get({ id: 'translate:target:language' })
|
||||||
targetLang && setTargetLanguage(targetLang.value)
|
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,69 +391,42 @@ const TranslatePage: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingButton = () => {
|
const handleInputScroll = createInputScrollHandler(outputTextRef, isProgrammaticScroll, isScrollSyncEnabled)
|
||||||
if (isLocalAi) {
|
const handleOutputScroll = createOutputScrollHandler(textAreaRef, isProgrammaticScroll, isScrollSyncEnabled)
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (translateModel) {
|
// 获取当前语言状态显示
|
||||||
|
const getLanguageDisplay = () => {
|
||||||
|
if (isBidirectional) {
|
||||||
return (
|
return (
|
||||||
<Link to="/settings/model" style={{ color: 'var(--color-text-2)', display: 'flex' }}>
|
<Flex align="center" style={{ width: 160 }}>
|
||||||
<Settings2 size={18} />
|
<BidirectionalLanguageDisplay>
|
||||||
</Link>
|
{`${t(`languages.${bidirectionalPair[0]}`)} ⇆ ${t(`languages.${bidirectionalPair[1]}`)}`}
|
||||||
|
</BidirectionalLanguageDisplay>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to="/settings/model" style={{ marginLeft: -10, display: 'flex' }}>
|
<Select
|
||||||
<Button
|
style={{ width: 160 }}
|
||||||
type="link"
|
value={targetLanguage}
|
||||||
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
|
onChange={(value) => {
|
||||||
icon={<TriangleAlert size={16} />}>
|
setTargetLanguage(value)
|
||||||
{t('translate.error.not_configured')}
|
db.settings.put({ id: 'translate:target:language', value })
|
||||||
</Button>
|
}}
|
||||||
</Link>
|
options={translateLanguageOptions().map((lang) => ({
|
||||||
|
value: lang.value,
|
||||||
|
label: (
|
||||||
|
<Space.Compact direction="horizontal" block>
|
||||||
|
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||||
|
{lang.emoji}
|
||||||
|
</span>
|
||||||
|
<Space.Compact block>{lang.label}</Space.Compact>
|
||||||
|
</Space.Compact>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Handle input area scroll event
|
|
||||||
const handleInputScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
|
||||||
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<HTMLDivElement>) => {
|
|
||||||
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 (
|
return (
|
||||||
@ -275,23 +498,23 @@ const TranslatePage: FC = () => {
|
|||||||
<Flex align="center" gap={20}>
|
<Flex align="center" gap={20}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
value="any"
|
value="auto"
|
||||||
style={{ width: 180 }}
|
style={{ width: 180 }}
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
disabled
|
disabled
|
||||||
options={[{ label: t('translate.any.language'), value: 'any' }]}
|
options={[
|
||||||
|
{
|
||||||
|
label: detectedLanguage ? t(`languages.${detectedLanguage}`) : t('translate.detected.language'),
|
||||||
|
value: 'auto'
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<SettingButton />
|
<Button
|
||||||
<Tooltip
|
type="text"
|
||||||
mouseEnterDelay={0.5}
|
icon={<Settings2 size={18} />}
|
||||||
title={isScrollSyncEnabled ? t('translate.scroll_sync.disable') : t('translate.scroll_sync.enable')}>
|
onClick={() => setSettingsVisible(true)}
|
||||||
<Mouse
|
style={{ color: 'var(--color-text-2)', display: 'flex' }}
|
||||||
size={16}
|
|
||||||
onClick={toggleScrollSync}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
color={isScrollSyncEnabled ? 'var(--color-primary)' : 'var(--color-icon)'}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@ -331,25 +554,9 @@ const TranslatePage: FC = () => {
|
|||||||
|
|
||||||
<OutputContainer>
|
<OutputContainer>
|
||||||
<OperationBar>
|
<OperationBar>
|
||||||
<Select
|
<HStack alignItems="center" gap={5}>
|
||||||
showSearch
|
{getLanguageDisplay()}
|
||||||
value={targetLanguage}
|
</HStack>
|
||||||
style={{ width: 180 }}
|
|
||||||
optionFilterProp="label"
|
|
||||||
options={translateLanguageOptions()}
|
|
||||||
onChange={(value) => {
|
|
||||||
setTargetLanguage(value)
|
|
||||||
db.settings.put({ id: 'translate:target:language', value })
|
|
||||||
}}
|
|
||||||
optionRender={(option) => (
|
|
||||||
<Space>
|
|
||||||
<span role="img" aria-label={option.data.label}>
|
|
||||||
{option.data.emoji}
|
|
||||||
</span>
|
|
||||||
{option.label}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<CopyButton
|
<CopyButton
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
disabled={!result}
|
disabled={!result}
|
||||||
@ -362,6 +569,21 @@ const TranslatePage: FC = () => {
|
|||||||
</OutputText>
|
</OutputText>
|
||||||
</OutputContainer>
|
</OutputContainer>
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
|
|
||||||
|
<TranslateSettings
|
||||||
|
visible={settingsVisible}
|
||||||
|
onClose={() => setSettingsVisible(false)}
|
||||||
|
isScrollSyncEnabled={isScrollSyncEnabled}
|
||||||
|
setIsScrollSyncEnabled={setIsScrollSyncEnabled}
|
||||||
|
isBidirectional={isBidirectional}
|
||||||
|
setIsBidirectional={toggleBidirectional}
|
||||||
|
bidirectionalPair={bidirectionalPair}
|
||||||
|
setBidirectionalPair={setBidirectionalPair}
|
||||||
|
translateModel={translateModel}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
allModels={allModels}
|
||||||
|
selectOptions={selectOptions}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -437,6 +659,16 @@ const TranslateButton = styled(Button)``
|
|||||||
|
|
||||||
const CopyButton = 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 }>`
|
const HistoryContainner = styled.div<{ $historyDrawerVisible: boolean }>`
|
||||||
width: ${({ $historyDrawerVisible }) => ($historyDrawerVisible ? '300px' : '0')};
|
width: ${({ $historyDrawerVisible }) => ($historyDrawerVisible ? '300px' : '0')};
|
||||||
height: calc(100vh - var(--navbar-height) - 40px);
|
height: calc(100vh - var(--navbar-height) - 40px);
|
||||||
|
|||||||
216
src/renderer/src/utils/translate.ts
Normal file
216
src/renderer/src/utils/translate.ts
Normal file
@ -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<string>} 检测到的语言代码
|
||||||
|
*/
|
||||||
|
export const detectLanguage = async (inputText: string): Promise<string> => {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<boolean>
|
||||||
|
): 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<HTMLDivElement | null>,
|
||||||
|
isProgrammaticScrollRef: MutableRefObject<boolean>,
|
||||||
|
isScrollSyncEnabled: boolean
|
||||||
|
) => {
|
||||||
|
return (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!isScrollSyncEnabled || !targetRef.current || isProgrammaticScrollRef.current) return
|
||||||
|
handleScrollSync(e.currentTarget, targetRef.current, isProgrammaticScrollRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建输出区域滚动处理函数
|
||||||
|
*/
|
||||||
|
export const createOutputScrollHandler = (
|
||||||
|
textAreaRef: MutableRefObject<any>,
|
||||||
|
isProgrammaticScrollRef: MutableRefObject<boolean>,
|
||||||
|
isScrollSyncEnabled: boolean
|
||||||
|
) => {
|
||||||
|
return (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const inputEl = textAreaRef.current?.resizableTextArea?.textArea
|
||||||
|
if (!isScrollSyncEnabled || !inputEl || isProgrammaticScrollRef.current) return
|
||||||
|
handleScrollSync(e.currentTarget, inputEl, isProgrammaticScrollRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
yarn.lock
34
yarn.lock
@ -5621,6 +5621,7 @@ __metadata:
|
|||||||
eslint-plugin-unused-imports: "npm:^4.1.4"
|
eslint-plugin-unused-imports: "npm:^4.1.4"
|
||||||
fast-diff: "npm:^1.3.0"
|
fast-diff: "npm:^1.3.0"
|
||||||
fast-xml-parser: "npm:^5.2.0"
|
fast-xml-parser: "npm:^5.2.0"
|
||||||
|
franc: "npm:^6.2.0"
|
||||||
fs-extra: "npm:^11.2.0"
|
fs-extra: "npm:^11.2.0"
|
||||||
html-to-image: "npm:^1.11.13"
|
html-to-image: "npm:^1.11.13"
|
||||||
husky: "npm:^9.1.7"
|
husky: "npm:^9.1.7"
|
||||||
@ -6953,6 +6954,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"color-convert@npm:^2.0.1":
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
resolution: "color-convert@npm:2.0.1"
|
resolution: "color-convert@npm:2.0.1"
|
||||||
@ -9812,6 +9820,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"fresh@npm:^2.0.0":
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
resolution: "fresh@npm:2.0.0"
|
resolution: "fresh@npm:2.0.0"
|
||||||
@ -13543,6 +13560,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"nanoid@npm:^3.3.7, nanoid@npm:^3.3.8":
|
||||||
version: 3.3.11
|
version: 3.3.11
|
||||||
resolution: "nanoid@npm:3.3.11"
|
resolution: "nanoid@npm:3.3.11"
|
||||||
@ -17509,6 +17533,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"trim-lines@npm:^3.0.0":
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
resolution: "trim-lines@npm:3.0.1"
|
resolution: "trim-lines@npm:3.0.1"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user