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 1126a5cce9.

* Reapply "fix: update"

This reverts commit 82b7890f92.

* fix: setloading missing
This commit is contained in:
自由的世界人 2025-05-30 13:49:39 +08:00 committed by GitHub
parent a5d10e1ef3
commit 65104ad570
9 changed files with 648 additions and 120 deletions

View File

@ -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",

View File

@ -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": {

View File

@ -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": "終了",

View File

@ -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": "Выйти",

View File

@ -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": "退出",

View File

@ -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": "結束",

View File

@ -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 (
<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 { 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<string | null>(null)
const contentContainerRef = useRef<HTMLDivElement>(null)
const textAreaRef = useRef<TextAreaRef>(null)
const outputTextRef = useRef<HTMLDivElement>(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 (
<Link to="/settings/model" style={{ color: 'var(--color-text-2)', display: 'flex' }}>
<Settings2 size={18} />
</Link>
<Flex align="center" style={{ width: 160 }}>
<BidirectionalLanguageDisplay>
{`${t(`languages.${bidirectionalPair[0]}`)}${t(`languages.${bidirectionalPair[1]}`)}`}
</BidirectionalLanguageDisplay>
</Flex>
)
}
return (
<Link to="/settings/model" style={{ marginLeft: -10, display: 'flex' }}>
<Button
type="link"
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
icon={<TriangleAlert size={16} />}>
{t('translate.error.not_configured')}
</Button>
</Link>
<Select
style={{ width: 160 }}
value={targetLanguage}
onChange={(value) => {
setTargetLanguage(value)
db.settings.put({ id: 'translate:target:language', 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>
<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 (
<Container id="translate-page">
<Navbar>
@ -275,23 +498,23 @@ const TranslatePage: FC = () => {
<Flex align="center" gap={20}>
<Select
showSearch
value="any"
value="auto"
style={{ width: 180 }}
optionFilterProp="label"
disabled
options={[{ label: t('translate.any.language'), value: 'any' }]}
options={[
{
label: detectedLanguage ? t(`languages.${detectedLanguage}`) : t('translate.detected.language'),
value: 'auto'
}
]}
/>
<Button
type="text"
icon={<Settings2 size={18} />}
onClick={() => setSettingsVisible(true)}
style={{ color: 'var(--color-text-2)', display: 'flex' }}
/>
<SettingButton />
<Tooltip
mouseEnterDelay={0.5}
title={isScrollSyncEnabled ? t('translate.scroll_sync.disable') : t('translate.scroll_sync.enable')}>
<Mouse
size={16}
onClick={toggleScrollSync}
style={{ cursor: 'pointer' }}
color={isScrollSyncEnabled ? 'var(--color-primary)' : 'var(--color-icon)'}
/>
</Tooltip>
</Flex>
<Tooltip
@ -331,25 +554,9 @@ const TranslatePage: FC = () => {
<OutputContainer>
<OperationBar>
<Select
showSearch
value={targetLanguage}
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>
)}
/>
<HStack alignItems="center" gap={5}>
{getLanguageDisplay()}
</HStack>
<CopyButton
onClick={onCopy}
disabled={!result}
@ -362,6 +569,21 @@ const TranslatePage: FC = () => {
</OutputText>
</OutputContainer>
</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>
)
}
@ -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);

View 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)
}
}

View File

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