refactor(ocr): simplify image provider state management

- Remove unnecessary state propagation between components
- Store image provider ID in preferences instead of redux
- Add null checks for provider existence
- Update tab navigation to use new ui components
This commit is contained in:
icarus 2025-10-19 22:32:00 +08:00
parent 2c78f5f906
commit d35d7029f7
5 changed files with 81 additions and 60 deletions

View File

@ -351,6 +351,8 @@ export interface PreferenceSchemas {
'feature.translate.model_prompt': string 'feature.translate.model_prompt': string
// redux/settings/targetLanguage // redux/settings/targetLanguage
'feature.translate.target_language': string 'feature.translate.target_language': string
// redux/ocr/imageProviderId
'ocr.settings.image_provider_id': string | null
// redux/shortcuts/shortcuts.exit_fullscreen // redux/shortcuts/shortcuts.exit_fullscreen
'shortcut.app.exit_fullscreen': Record<string, unknown> 'shortcut.app.exit_fullscreen': Record<string, unknown>
// redux/shortcuts/shortcuts.search_message // redux/shortcuts/shortcuts.search_message
@ -612,6 +614,7 @@ export const DefaultPreferences: PreferenceSchemas = {
'feature.selection.trigger_mode': PreferenceTypes.SelectionTriggerMode.Selected, 'feature.selection.trigger_mode': PreferenceTypes.SelectionTriggerMode.Selected,
'feature.translate.model_prompt': TRANSLATE_PROMPT, 'feature.translate.model_prompt': TRANSLATE_PROMPT,
'feature.translate.target_language': 'en-us', 'feature.translate.target_language': 'en-us',
'ocr.settings.image_provider_id': null,
'shortcut.app.exit_fullscreen': { editable: false, enabled: true, key: ['Escape'], system: true }, 'shortcut.app.exit_fullscreen': { editable: false, enabled: true, key: ['Escape'], system: true },
'shortcut.app.search_message': { 'shortcut.app.search_message': {
editable: true, editable: true,

View File

@ -1,16 +1,17 @@
import { Avatar } from '@cherrystudio/ui' import { Avatar } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import IntelLogo from '@renderer/assets/images/providers/intel.png' import IntelLogo from '@renderer/assets/images/providers/intel.png'
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png' import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png' import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' import { BUILTIN_OCR_PROVIDERS_MAP } from '@renderer/config/ocr'
import { getBuiltinOcrProviderLabel } from '@renderer/i18n/label' import { getBuiltinOcrProviderLabel } from '@renderer/i18n/label'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { addOcrProvider, removeOcrProvider, setImageOcrProviderId, updateOcrProviderConfig } from '@renderer/store/ocr' import { addOcrProvider, removeOcrProvider, updateOcrProviderConfig } from '@renderer/store/ocr'
import type { ImageOcrProvider, OcrProvider, OcrProviderConfig } from '@renderer/types' import type { OcrProvider, OcrProviderConfig } from '@renderer/types'
import { isBuiltinOcrProvider, isBuiltinOcrProviderId, isImageOcrProvider } from '@renderer/types' import { isBuiltinOcrProvider, isBuiltinOcrProviderId, isImageOcrProvider } from '@renderer/types'
import { FileQuestionMarkIcon, MonitorIcon } from 'lucide-react' import { FileQuestionMarkIcon, MonitorIcon } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
@ -19,8 +20,10 @@ const logger = loggerService.withContext('useOcrProvider')
export const useOcrProviders = () => { export const useOcrProviders = () => {
const providers = useAppSelector((state) => state.ocr.providers) const providers = useAppSelector((state) => state.ocr.providers)
const imageProviders = providers.filter(isImageOcrProvider) const imageProviders = providers.filter(isImageOcrProvider)
const imageProviderId = useAppSelector((state) => state.ocr.imageProviderId) const [imageProviderId, setImageProviderId] = usePreference('ocr.settings.image_provider_id')
const [imageProvider, setImageProvider] = useState<ImageOcrProvider>(DEFAULT_OCR_PROVIDER.image) const imageProvider = useMemo(() => {
return imageProviders.find((p) => p.id === imageProviderId)
}, [imageProviderId, imageProviders])
const dispatch = useDispatch() const dispatch = useDispatch()
const { t } = useTranslation() const { t } = useTranslation()
@ -58,13 +61,6 @@ export const useOcrProviders = () => {
dispatch(removeOcrProvider(id)) dispatch(removeOcrProvider(id))
} }
const setImageProviderId = useCallback(
(id: string) => {
dispatch(setImageOcrProviderId(id))
},
[dispatch]
)
const getOcrProviderName = (p: OcrProvider) => { const getOcrProviderName = (p: OcrProvider) => {
return isBuiltinOcrProvider(p) ? getBuiltinOcrProviderLabel(p.id) : p.name return isBuiltinOcrProvider(p) ? getBuiltinOcrProviderLabel(p.id) : p.name
} }
@ -85,21 +81,6 @@ export const useOcrProviders = () => {
return <FileQuestionMarkIcon size={size} /> return <FileQuestionMarkIcon size={size} />
} }
useEffect(() => {
const actualImageProvider = imageProviders.find((p) => p.id === imageProviderId)
if (!actualImageProvider) {
if (isBuiltinOcrProviderId(imageProviderId)) {
logger.warn(`Builtin ocr provider ${imageProviderId} not exist. Will add it to providers.`)
addProvider(BUILTIN_OCR_PROVIDERS_MAP[imageProviderId])
}
setImageProviderId(DEFAULT_OCR_PROVIDER.image.id)
setImageProvider(DEFAULT_OCR_PROVIDER.image)
} else {
setImageProviderId(actualImageProvider.id)
setImageProvider(actualImageProvider)
}
}, [addProvider, imageProviderId, imageProviders, setImageProviderId])
return { return {
providers, providers,
imageProvider, imageProvider,

View File

@ -3,11 +3,11 @@ import { loggerService } from '@logger'
import { ErrorTag } from '@renderer/components/Tags/ErrorTag' import { ErrorTag } from '@renderer/components/Tags/ErrorTag'
import { isMac, isWin } from '@renderer/config/constant' import { isMac, isWin } from '@renderer/config/constant'
import { useOcrProviders } from '@renderer/hooks/useOcrProvider' import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
import type { ImageOcrProvider, OcrProvider } from '@renderer/types' import type { ImageOcrProvider } from '@renderer/types'
import { BuiltinOcrProviderIds, isImageOcrProvider } from '@renderer/types' import { BuiltinOcrProviderIds, isImageOcrProvider } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils' import { getErrorMessage } from '@renderer/utils'
import { Select } from 'antd' import { Select } from 'antd'
import { useCallback, useEffect, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable' import useSWRImmutable from 'swr/immutable'
@ -15,11 +15,7 @@ import { SettingRow, SettingRowTitle } from '..'
const logger = loggerService.withContext('OcrImageSettings') const logger = loggerService.withContext('OcrImageSettings')
type Props = { const OcrImageSettings = () => {
setProvider: (provider: OcrProvider) => void
}
const OcrImageSettings = ({ setProvider }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders() const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders()
const fetcher = useCallback(() => { const fetcher = useCallback(() => {
@ -30,12 +26,6 @@ const OcrImageSettings = ({ setProvider }: Props) => {
const imageProviders = providers.filter((p) => isImageOcrProvider(p)) const imageProviders = providers.filter((p) => isImageOcrProvider(p))
// 挂载时更新外部状态
// FIXME: Just keep the imageProvider always valid, so we don't need update it in this component.
useEffect(() => {
setProvider(imageProvider)
}, [imageProvider, setProvider])
const setImageProvider = (id: string) => { const setImageProvider = (id: string) => {
const provider = imageProviders.find((p) => p.id === id) const provider = imageProviders.find((p) => p.id === id)
if (!provider) { if (!provider) {
@ -44,7 +34,6 @@ const OcrImageSettings = ({ setProvider }: Props) => {
return return
} }
setProvider(provider)
setImageProviderId(id) setImageProviderId(id)
} }
@ -62,7 +51,11 @@ const OcrImageSettings = ({ setProvider }: Props) => {
})) }))
}, [getOcrProviderName, imageProviders, platformSupport, validProviders]) }, [getOcrProviderName, imageProviders, platformSupport, validProviders])
const isSystem = imageProvider.id === BuiltinOcrProviderIds.system const isSystem = imageProvider?.id === BuiltinOcrProviderIds.system
if (!imageProvider) {
return <Alert color="danger" title={t('ocr.error.provider.not_found')} />
}
return ( return (
<> <>

View File

@ -18,14 +18,14 @@ import { OcrTesseractSettings } from './OcrTesseractSettings'
// const logger = loggerService.withContext('OcrTesseractSettings') // const logger = loggerService.withContext('OcrTesseractSettings')
type Props = { type Props = {
provider: OcrProvider provider: OcrProvider | undefined
} }
const OcrProviderSettings = ({ provider }: Props) => { const OcrProviderSettings = ({ provider }: Props) => {
const { theme: themeMode } = useTheme() const { theme: themeMode } = useTheme()
const { OcrProviderLogo, getOcrProviderName } = useOcrProviders() const { OcrProviderLogo, getOcrProviderName } = useOcrProviders()
if (!isWin && !isMac && isOcrSystemProvider(provider)) { if (!provider || (!isWin && !isMac && isOcrSystemProvider(provider))) {
return null return null
} }

View File

@ -1,40 +1,84 @@
import { PictureOutlined } from '@ant-design/icons' import { PictureOutlined } from '@ant-design/icons'
import { cn, Tabs, TabsContent, TabsList, TabsTrigger } from '@cherrystudio/ui'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary' import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useOcrProviders } from '@renderer/hooks/useOcrProvider' import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
import type { OcrProvider } from '@renderer/types' import type { FC, ReactNode } from 'react'
import type { TabsProps } from 'antd' import { useCallback, useMemo, useState } from 'react'
import { Tabs } from 'antd'
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import * as z from 'zod'
import { SettingDivider, SettingGroup, SettingTitle } from '..' import { SettingDivider, SettingGroup, SettingTitle } from '..'
import OcrImageSettings from './OcrImageSettings' import OcrImageSettings from './OcrImageSettings'
import OcrProviderSettings from './OcrProviderSettings' import OcrProviderSettings from './OcrProviderSettings'
const TabSchema = z.enum(['image'])
type Tab = z.infer<typeof TabSchema>
const isValidTab = (value: string): value is Tab => TabSchema.safeParse(value).success
type TabItem = {
name: string
value: Tab
icon: ReactNode
content: ReactNode
}
const OcrSettings: FC = () => { const OcrSettings: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { theme: themeMode } = useTheme() const { theme: themeMode } = useTheme()
const { imageProvider } = useOcrProviders() const { imageProvider } = useOcrProviders()
const [provider, setProvider] = useState<OcrProvider>(imageProvider) // since default to image provider const [activeTab, setActiveTab] = useState<Tab>('image')
const provider = useMemo(() => {
const tabs: TabsProps['items'] = [ switch (activeTab) {
{ case 'image':
key: 'image', return imageProvider
label: t('settings.tool.ocr.image.title'),
icon: <PictureOutlined />,
children: <OcrImageSettings setProvider={setProvider} />
} }
] }, [imageProvider, activeTab])
const tabs = [
{
name: t('settings.tool.ocr.image.title'),
value: 'image',
icon: <PictureOutlined />,
content: <OcrImageSettings />
}
] satisfies TabItem[]
const handleTabChange = useCallback((value: string) => {
if (isValidTab(value)) {
setActiveTab(value)
} else {
window.toast.error('Unexpected behavior: Not a valid tab.')
}
}, [])
return ( return (
<ErrorBoundary> <ErrorBoundary>
<SettingGroup theme={themeMode}> <SettingGroup theme={themeMode}>
<SettingTitle>{t('settings.tool.ocr.title')}</SettingTitle> <SettingTitle>{t('settings.tool.ocr.title')}</SettingTitle>
<SettingDivider /> <SettingDivider />
<Tabs defaultActiveKey="image" items={tabs} /> <Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
{tabs.map((tab) => {
return (
<TabsTrigger key={tab.value} value={tab.value} className="cursor-pointer">
<div className={cn('flex items-center gap-1', tab.value === activeTab && 'text-primary')}>
{tab.icon}
{tab.name}
</div>
</TabsTrigger>
)
})}
</TabsList>
{tabs.map((tab) => {
return (
<TabsContent key={tab.value} value={tab.value} className="pl-1">
{tab.content}
</TabsContent>
)
})}
</Tabs>
</SettingGroup> </SettingGroup>
<ErrorBoundary> <ErrorBoundary>
<OcrProviderSettings provider={provider} /> <OcrProviderSettings provider={provider} />
</ErrorBoundary> </ErrorBoundary>