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
// redux/settings/targetLanguage
'feature.translate.target_language': string
// redux/ocr/imageProviderId
'ocr.settings.image_provider_id': string | null
// redux/shortcuts/shortcuts.exit_fullscreen
'shortcut.app.exit_fullscreen': Record<string, unknown>
// redux/shortcuts/shortcuts.search_message
@ -612,6 +614,7 @@ export const DefaultPreferences: PreferenceSchemas = {
'feature.selection.trigger_mode': PreferenceTypes.SelectionTriggerMode.Selected,
'feature.translate.model_prompt': TRANSLATE_PROMPT,
'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.search_message': {
editable: true,

View File

@ -1,16 +1,17 @@
import { Avatar } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import IntelLogo from '@renderer/assets/images/providers/intel.png'
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.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 { useAppSelector } from '@renderer/store'
import { addOcrProvider, removeOcrProvider, setImageOcrProviderId, updateOcrProviderConfig } from '@renderer/store/ocr'
import type { ImageOcrProvider, OcrProvider, OcrProviderConfig } from '@renderer/types'
import { addOcrProvider, removeOcrProvider, updateOcrProviderConfig } from '@renderer/store/ocr'
import type { OcrProvider, OcrProviderConfig } from '@renderer/types'
import { isBuiltinOcrProvider, isBuiltinOcrProviderId, isImageOcrProvider } from '@renderer/types'
import { FileQuestionMarkIcon, MonitorIcon } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
@ -19,8 +20,10 @@ const logger = loggerService.withContext('useOcrProvider')
export const useOcrProviders = () => {
const providers = useAppSelector((state) => state.ocr.providers)
const imageProviders = providers.filter(isImageOcrProvider)
const imageProviderId = useAppSelector((state) => state.ocr.imageProviderId)
const [imageProvider, setImageProvider] = useState<ImageOcrProvider>(DEFAULT_OCR_PROVIDER.image)
const [imageProviderId, setImageProviderId] = usePreference('ocr.settings.image_provider_id')
const imageProvider = useMemo(() => {
return imageProviders.find((p) => p.id === imageProviderId)
}, [imageProviderId, imageProviders])
const dispatch = useDispatch()
const { t } = useTranslation()
@ -58,13 +61,6 @@ export const useOcrProviders = () => {
dispatch(removeOcrProvider(id))
}
const setImageProviderId = useCallback(
(id: string) => {
dispatch(setImageOcrProviderId(id))
},
[dispatch]
)
const getOcrProviderName = (p: OcrProvider) => {
return isBuiltinOcrProvider(p) ? getBuiltinOcrProviderLabel(p.id) : p.name
}
@ -85,21 +81,6 @@ export const useOcrProviders = () => {
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 {
providers,
imageProvider,

View File

@ -3,11 +3,11 @@ import { loggerService } from '@logger'
import { ErrorTag } from '@renderer/components/Tags/ErrorTag'
import { isMac, isWin } from '@renderer/config/constant'
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 { getErrorMessage } from '@renderer/utils'
import { Select } from 'antd'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
@ -15,11 +15,7 @@ import { SettingRow, SettingRowTitle } from '..'
const logger = loggerService.withContext('OcrImageSettings')
type Props = {
setProvider: (provider: OcrProvider) => void
}
const OcrImageSettings = ({ setProvider }: Props) => {
const OcrImageSettings = () => {
const { t } = useTranslation()
const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders()
const fetcher = useCallback(() => {
@ -30,12 +26,6 @@ const OcrImageSettings = ({ setProvider }: Props) => {
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 provider = imageProviders.find((p) => p.id === id)
if (!provider) {
@ -44,7 +34,6 @@ const OcrImageSettings = ({ setProvider }: Props) => {
return
}
setProvider(provider)
setImageProviderId(id)
}
@ -62,7 +51,11 @@ const OcrImageSettings = ({ setProvider }: Props) => {
}))
}, [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 (
<>

View File

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

View File

@ -1,40 +1,84 @@
import { PictureOutlined } from '@ant-design/icons'
import { cn, Tabs, TabsContent, TabsList, TabsTrigger } from '@cherrystudio/ui'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
import type { OcrProvider } from '@renderer/types'
import type { TabsProps } from 'antd'
import { Tabs } from 'antd'
import type { FC } from 'react'
import { useState } from 'react'
import type { FC, ReactNode } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
import { SettingDivider, SettingGroup, SettingTitle } from '..'
import OcrImageSettings from './OcrImageSettings'
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 { t } = useTranslation()
const { theme: themeMode } = useTheme()
const { imageProvider } = useOcrProviders()
const [provider, setProvider] = useState<OcrProvider>(imageProvider) // since default to image provider
const tabs: TabsProps['items'] = [
{
key: 'image',
label: t('settings.tool.ocr.image.title'),
icon: <PictureOutlined />,
children: <OcrImageSettings setProvider={setProvider} />
const [activeTab, setActiveTab] = useState<Tab>('image')
const provider = useMemo(() => {
switch (activeTab) {
case 'image':
return imageProvider
}
]
}, [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 (
<ErrorBoundary>
<SettingGroup theme={themeMode}>
<SettingTitle>{t('settings.tool.ocr.title')}</SettingTitle>
<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>
<ErrorBoundary>
<OcrProviderSettings provider={provider} />
</ErrorBoundary>