mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 18:45:21 +00:00
Merge remote-tracking branch 'origin/V2' into feature/react-convert
This commit is contained in:
commit
f1a612993f
237
frontend/src/components/shared/DropdownListWithFooter.tsx
Normal file
237
frontend/src/components/shared/DropdownListWithFooter.tsx
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import React, { ReactNode, useState, useMemo } from 'react';
|
||||||
|
import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from '@mantine/core';
|
||||||
|
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
|
||||||
|
export interface DropdownItem {
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
leftIcon?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropdownListWithFooterProps {
|
||||||
|
// Value and onChange - support both single and multi-select
|
||||||
|
value: string | string[];
|
||||||
|
onChange: (value: string | string[]) => void;
|
||||||
|
|
||||||
|
// Items and display
|
||||||
|
items: DropdownItem[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
// Labels and headers
|
||||||
|
label?: string;
|
||||||
|
header?: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
multiSelect?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
maxHeight?: number;
|
||||||
|
|
||||||
|
// Styling
|
||||||
|
className?: string;
|
||||||
|
dropdownClassName?: string;
|
||||||
|
|
||||||
|
// Popover props
|
||||||
|
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
withArrow?: boolean;
|
||||||
|
width?: 'target' | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
items,
|
||||||
|
placeholder = 'Select option',
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
multiSelect = false,
|
||||||
|
searchable = false,
|
||||||
|
maxHeight = 300,
|
||||||
|
className = '',
|
||||||
|
dropdownClassName = '',
|
||||||
|
position = 'bottom',
|
||||||
|
withArrow = false,
|
||||||
|
width = 'target'
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const isMultiValue = Array.isArray(value);
|
||||||
|
const selectedValues = isMultiValue ? value : (value ? [value] : []);
|
||||||
|
|
||||||
|
// Filter items based on search term
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!searchable || !searchTerm.trim()) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return items.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [items, searchTerm, searchable]);
|
||||||
|
|
||||||
|
const handleItemClick = (itemValue: string) => {
|
||||||
|
if (multiSelect) {
|
||||||
|
const newSelection = selectedValues.includes(itemValue)
|
||||||
|
? selectedValues.filter(v => v !== itemValue)
|
||||||
|
: [...selectedValues, itemValue];
|
||||||
|
onChange(newSelection);
|
||||||
|
} else {
|
||||||
|
onChange(itemValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedValues.length === 0) {
|
||||||
|
return placeholder;
|
||||||
|
} else if (selectedValues.length === 1) {
|
||||||
|
const selectedItem = items.find(item => item.value === selectedValues[0]);
|
||||||
|
return selectedItem?.name || selectedValues[0];
|
||||||
|
} else {
|
||||||
|
return `${selectedValues.length} selected`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchTerm(event.currentTarget.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={className}>
|
||||||
|
{label && (
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
width={width}
|
||||||
|
position={position}
|
||||||
|
withArrow={withArrow}
|
||||||
|
shadow="md"
|
||||||
|
onClose={() => searchable && setSearchTerm('')}
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
minHeight: '36px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" style={{ flex: 1 }}>
|
||||||
|
{getDisplayText()}
|
||||||
|
</Text>
|
||||||
|
<UnfoldMoreIcon style={{
|
||||||
|
fontSize: '1rem',
|
||||||
|
color: 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2))'
|
||||||
|
}} />
|
||||||
|
</Box>
|
||||||
|
</Popover.Target>
|
||||||
|
|
||||||
|
<Popover.Dropdown className={dropdownClassName}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{header && (
|
||||||
|
<Box style={{
|
||||||
|
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
|
||||||
|
paddingBottom: '8px'
|
||||||
|
}}>
|
||||||
|
{header}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchable && (
|
||||||
|
<Box style={{
|
||||||
|
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
|
||||||
|
paddingBottom: '8px'
|
||||||
|
}}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
leftSection={<SearchIcon style={{ fontSize: '1rem' }} />}
|
||||||
|
size="sm"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box style={{ maxHeight, overflowY: 'auto' }}>
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<Box style={{ padding: '12px', textAlign: 'center' }}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{searchable && searchTerm ? 'No results found' : 'No items available'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
filteredItems.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => !item.disabled && handleItemClick(item.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: item.disabled ? 'not-allowed' : 'pointer',
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
opacity: item.disabled ? 0.5 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!item.disabled) {
|
||||||
|
e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" style={{ flex: 1 }}>
|
||||||
|
{item.leftIcon && (
|
||||||
|
<Box style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{item.leftIcon}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Text size="sm">{item.name}</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{multiSelect && (
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedValues.includes(item.value)}
|
||||||
|
onChange={() => {}} // Handled by parent onClick
|
||||||
|
size="sm"
|
||||||
|
disabled={item.disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<Box style={{
|
||||||
|
borderTop: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
|
||||||
|
paddingTop: '8px'
|
||||||
|
}}>
|
||||||
|
{footer}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownListWithFooter;
|
@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
/* Dark theme support */
|
/* Dark theme support */
|
||||||
[data-mantine-color-scheme="dark"] .languageItem {
|
[data-mantine-color-scheme="dark"] .languageItem {
|
||||||
border-right-color: var(--mantine-color-dark-4);
|
border-right-color: var(--mantine-color-dark-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) {
|
[data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) {
|
||||||
@ -52,11 +52,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) {
|
[data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) {
|
||||||
border-right-color: var(--mantine-color-dark-4);
|
border-right-color: var(--mantine-color-dark-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) {
|
[data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) {
|
||||||
border-right-color: var(--mantine-color-dark-4);
|
border-right-color: var(--mantine-color-dark-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive text visibility */
|
/* Responsive text visibility */
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
|
import { Menu, Button, ScrollArea } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { supportedLanguages } from '../../i18n';
|
import { supportedLanguages } from '../../i18n';
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
import LanguageIcon from '@mui/icons-material/Language';
|
||||||
@ -7,8 +7,6 @@ import styles from './LanguageSelector.module.css';
|
|||||||
|
|
||||||
const LanguageSelector = () => {
|
const LanguageSelector = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const theme = useMantineTheme();
|
|
||||||
const { colorScheme } = useMantineColorScheme();
|
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||||
const [isChanging, setIsChanging] = useState(false);
|
const [isChanging, setIsChanging] = useState(false);
|
||||||
@ -102,10 +100,10 @@ const LanguageSelector = () => {
|
|||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
|
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
||||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
|
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
@ -125,7 +123,8 @@ const LanguageSelector = () => {
|
|||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||||
border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`,
|
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
|
||||||
|
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ScrollArea h={190} type="scroll">
|
<ScrollArea h={190} type="scroll">
|
||||||
@ -145,6 +144,7 @@ const LanguageSelector = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={(event) => handleLanguageChange(option.value, event)}
|
onClick={(event) => handleLanguageChange(option.value, event)}
|
||||||
|
data-selected={option.value === i18n.language}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@ -153,21 +153,17 @@ const LanguageSelector = () => {
|
|||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: option.value === i18n.language ? (
|
backgroundColor: option.value === i18n.language
|
||||||
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
|
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
||||||
) : 'transparent',
|
: 'transparent',
|
||||||
color: option.value === i18n.language ? (
|
color: option.value === i18n.language
|
||||||
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7]
|
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||||
) : (
|
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
||||||
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
|
|
||||||
),
|
|
||||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: option.value === i18n.language ? (
|
backgroundColor: option.value === i18n.language
|
||||||
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
|
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
|
||||||
) : (
|
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||||
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
|
|
||||||
),
|
|
||||||
transform: 'translateY(-1px)',
|
transform: 'translateY(-1px)',
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
}
|
}
|
||||||
@ -197,7 +193,7 @@ const LanguageSelector = () => {
|
|||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: theme.colors.blue[4],
|
backgroundColor: 'var(--mantine-color-blue-4)',
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
90
frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx
Normal file
90
frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, Checkbox } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { OCRParameters } from './OCRSettings';
|
||||||
|
|
||||||
|
export interface AdvancedOCRParameters {
|
||||||
|
advancedOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdvancedOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
isSpecial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdvancedOCRSettingsProps {
|
||||||
|
advancedOptions: string[];
|
||||||
|
ocrRenderType?: string;
|
||||||
|
onParameterChange: (key: keyof OCRParameters, value: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdvancedOCRSettings: React.FC<AdvancedOCRSettingsProps> = ({
|
||||||
|
advancedOptions,
|
||||||
|
ocrRenderType = 'hocr',
|
||||||
|
onParameterChange,
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Define the advanced options available
|
||||||
|
const advancedOptionsData: AdvancedOption[] = [
|
||||||
|
{ value: 'compatibilityMode', label: t('ocr.settings.compatibilityMode.label', 'Compatibility Mode'), isSpecial: true },
|
||||||
|
{ value: 'sidecar', label: t('ocr.settings.advancedOptions.sidecar', 'Create a text file'), isSpecial: false },
|
||||||
|
{ value: 'deskew', label: t('ocr.settings.advancedOptions.deskew', 'Deskew pages'), isSpecial: false },
|
||||||
|
{ value: 'clean', label: t('ocr.settings.advancedOptions.clean', 'Clean input file'), isSpecial: false },
|
||||||
|
{ value: 'cleanFinal', label: t('ocr.settings.advancedOptions.cleanFinal', 'Clean final output'), isSpecial: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Handle individual checkbox changes
|
||||||
|
const handleCheckboxChange = (optionValue: string, checked: boolean) => {
|
||||||
|
const option = advancedOptionsData.find(opt => opt.value === optionValue);
|
||||||
|
|
||||||
|
if (option?.isSpecial) {
|
||||||
|
// Handle special options (like compatibility mode) differently
|
||||||
|
if (optionValue === 'compatibilityMode') {
|
||||||
|
onParameterChange('ocrRenderType', checked ? 'sandwich' : 'hocr');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle regular advanced options
|
||||||
|
const newOptions = checked
|
||||||
|
? [...advancedOptions, optionValue]
|
||||||
|
: advancedOptions.filter(option => option !== optionValue);
|
||||||
|
onParameterChange('additionalOptions', newOptions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a special option is selected
|
||||||
|
const isSpecialOptionSelected = (optionValue: string) => {
|
||||||
|
if (optionValue === 'compatibilityMode') {
|
||||||
|
return ocrRenderType === 'sandwich';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb="md">
|
||||||
|
{t('ocr.settings.advancedOptions.label', 'Processing Options')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
{advancedOptionsData.map((option) => (
|
||||||
|
<Checkbox
|
||||||
|
key={option.value}
|
||||||
|
checked={option.isSpecial ? isSpecialOptionSelected(option.value) : advancedOptions.includes(option.value)}
|
||||||
|
onChange={(event) => handleCheckboxChange(option.value, event.currentTarget.checked)}
|
||||||
|
label={option.label}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdvancedOCRSettings;
|
126
frontend/src/components/tools/ocr/LanguagePicker.module.css
Normal file
126
frontend/src/components/tools/ocr/LanguagePicker.module.css
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
/* Language Picker Component */
|
||||||
|
.languagePicker {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center; /* Center align items vertically */
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
background-color: var(--mantine-color-white); /* Use Mantine color variable */
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode background */
|
||||||
|
[data-mantine-color-scheme="dark"] .languagePicker {
|
||||||
|
background-color: var(--mantine-color-dark-6); /* Use Mantine dark color instead of hardcoded */
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePicker:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background-color: var(--mantine-color-gray-0); /* Light gray on hover for light mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode hover */
|
||||||
|
[data-mantine-color-scheme="dark"] .languagePicker:hover {
|
||||||
|
background-color: var(--mantine-color-dark-5); /* Use Mantine color variable */
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePicker:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center; /* Center the icon vertically */
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerDropdown {
|
||||||
|
background-color: var(--mantine-color-white); /* Use Mantine color variable */
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode dropdown background */
|
||||||
|
[data-mantine-color-scheme="dark"] .languagePickerDropdown {
|
||||||
|
background-color: var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerOption {
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerOptionWithCheckbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerCheckbox {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerOption:hover {
|
||||||
|
background-color: var(--mantine-color-gray-0); /* Light gray on hover for light mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode option hover */
|
||||||
|
[data-mantine-color-scheme="dark"] .languagePickerOption:hover {
|
||||||
|
background-color: var(--mantine-color-dark-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Additional helper classes for the component */
|
||||||
|
.languagePickerTarget {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerText {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerScrollArea {
|
||||||
|
max-height: 180px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerFooter {
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languagePickerLink {
|
||||||
|
color: var(--mantine-color-blue-6);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode link */
|
||||||
|
[data-mantine-color-scheme="dark"] .languagePickerLink {
|
||||||
|
color: var(--mantine-color-blue-4);
|
||||||
|
}
|
151
frontend/src/components/tools/ocr/LanguagePicker.tsx
Normal file
151
frontend/src/components/tools/ocr/LanguagePicker.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Text, Loader } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { tempOcrLanguages, getAutoOcrLanguage } from '../../../utils/languageMapping';
|
||||||
|
import DropdownListWithFooter, { DropdownItem } from '../../shared/DropdownListWithFooter';
|
||||||
|
|
||||||
|
export interface LanguageOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LanguagePickerProps {
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
languagesEndpoint?: string;
|
||||||
|
autoFillFromBrowserLanguage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguagePicker: React.FC<LanguagePickerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select languages',
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
languagesEndpoint = '/api/v1/ui-data/ocr-pdf',
|
||||||
|
autoFillFromBrowserLanguage = true,
|
||||||
|
}) => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [availableLanguages, setAvailableLanguages] = useState<DropdownItem[]>([]);
|
||||||
|
const [isLoadingLanguages, setIsLoadingLanguages] = useState(true);
|
||||||
|
const [hasAutoFilled, setHasAutoFilled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch available languages from backend
|
||||||
|
const fetchLanguages = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(languagesEndpoint);
|
||||||
|
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: { languages: string[] } = await response.json();
|
||||||
|
const languages = data.languages;
|
||||||
|
|
||||||
|
|
||||||
|
const languageOptions = languages.map(lang => {
|
||||||
|
// TODO: Use actual language translations when they become available
|
||||||
|
// For now, use temporary English translations
|
||||||
|
const translatedName = tempOcrLanguages.lang[lang as keyof typeof tempOcrLanguages.lang] || lang;
|
||||||
|
const displayName = translatedName;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: lang,
|
||||||
|
name: displayName
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setAvailableLanguages(languageOptions);
|
||||||
|
} else {
|
||||||
|
console.error('[LanguagePicker] Response not OK:', response.status, response.statusText);
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('[LanguagePicker] Error response body:', errorText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LanguagePicker] Fetch failed with error:', error);
|
||||||
|
console.error('[LanguagePicker] Error details:', {
|
||||||
|
name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoadingLanguages(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLanguages();
|
||||||
|
}, [languagesEndpoint]);
|
||||||
|
|
||||||
|
// Auto-fill OCR language based on browser language when languages are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldAutoFillLanguage = autoFillFromBrowserLanguage && !isLoadingLanguages && availableLanguages.length > 0 && !hasAutoFilled && value.length === 0;
|
||||||
|
|
||||||
|
if (shouldAutoFillLanguage) {
|
||||||
|
// Use the comprehensive language mapping from languageMapping.ts
|
||||||
|
const suggestedOcrLanguages = getAutoOcrLanguage(i18n.language);
|
||||||
|
|
||||||
|
if (suggestedOcrLanguages.length > 0) {
|
||||||
|
// Find the first suggested language that's available in the backend
|
||||||
|
const matchingLanguage = availableLanguages.find(lang =>
|
||||||
|
suggestedOcrLanguages.includes(lang.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingLanguage) {
|
||||||
|
onChange([matchingLanguage.value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasAutoFilled(true);
|
||||||
|
}
|
||||||
|
}, [autoFillFromBrowserLanguage, isLoadingLanguages, availableLanguages, hasAutoFilled, value.length, i18n.language, onChange]);
|
||||||
|
|
||||||
|
if (isLoadingLanguages) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm">Loading available languages...</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<Text size="xs" c="dimmed" className="text-center">
|
||||||
|
{t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
style={{
|
||||||
|
color: '#3b82f6',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onClick={() => window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')}
|
||||||
|
>
|
||||||
|
{t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownListWithFooter
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => onChange(newValue as string[])}
|
||||||
|
items={availableLanguages}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
label={label}
|
||||||
|
footer={footer}
|
||||||
|
multiSelect={true}
|
||||||
|
maxHeight={300}
|
||||||
|
searchable={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguagePicker;
|
54
frontend/src/components/tools/ocr/OCRSettings.tsx
Normal file
54
frontend/src/components/tools/ocr/OCRSettings.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Select, Text, Divider } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LanguagePicker from './LanguagePicker';
|
||||||
|
|
||||||
|
export interface OCRParameters {
|
||||||
|
languages: string[];
|
||||||
|
ocrType: string;
|
||||||
|
ocrRenderType: string;
|
||||||
|
additionalOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OCRSettingsProps {
|
||||||
|
parameters: OCRParameters;
|
||||||
|
onParameterChange: (key: keyof OCRParameters, value: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OCRSettings: React.FC<OCRSettingsProps> = ({
|
||||||
|
parameters,
|
||||||
|
onParameterChange,
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={t('ocr.settings.ocrMode.label', 'OCR Mode')}
|
||||||
|
value={parameters.ocrType}
|
||||||
|
onChange={(value) => onParameterChange('ocrType', value || 'skip-text')}
|
||||||
|
data={[
|
||||||
|
{ value: 'skip-text', label: t('ocr.settings.ocrMode.auto', 'Auto (skip text layers)') },
|
||||||
|
{ value: 'force-ocr', label: t('ocr.settings.ocrMode.force', 'Force (re-OCR all, replace text)') },
|
||||||
|
{ value: 'Normal', label: t('ocr.settings.ocrMode.strict', 'Strict (abort if text found)') },
|
||||||
|
]}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<LanguagePicker
|
||||||
|
value={parameters.languages || []}
|
||||||
|
onChange={(value) => onParameterChange('languages', value)}
|
||||||
|
placeholder={t('ocr.settings.languages.placeholder', 'Select languages')}
|
||||||
|
disabled={disabled}
|
||||||
|
label={t('ocr.settings.languages.label', 'Languages')}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OCRSettings;
|
@ -1,5 +1,7 @@
|
|||||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
||||||
import { Paper, Text, Stack, Box } from '@mantine/core';
|
import { Paper, Text, Stack, Box, Flex } from '@mantine/core';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
|
||||||
interface ToolStepContextType {
|
interface ToolStepContextType {
|
||||||
visibleStepCount: number;
|
visibleStepCount: number;
|
||||||
@ -33,7 +35,6 @@ const ToolStep = ({
|
|||||||
}: ToolStepProps) => {
|
}: ToolStepProps) => {
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
// Get context at the top level
|
|
||||||
const parent = useContext(ToolStepContext);
|
const parent = useContext(ToolStepContext);
|
||||||
|
|
||||||
// Auto-detect if we should show numbers based on sibling count
|
// Auto-detect if we should show numbers based on sibling count
|
||||||
@ -49,15 +50,45 @@ const ToolStep = ({
|
|||||||
p="md"
|
p="md"
|
||||||
withBorder
|
withBorder
|
||||||
style={{
|
style={{
|
||||||
cursor: isCollapsed && onCollapsedClick ? 'pointer' : 'default',
|
|
||||||
opacity: isCollapsed ? 0.8 : 1,
|
opacity: isCollapsed ? 0.8 : 1,
|
||||||
transition: 'opacity 0.2s ease'
|
transition: 'opacity 0.2s ease'
|
||||||
}}
|
}}
|
||||||
onClick={isCollapsed && onCollapsedClick ? onCollapsedClick : undefined}
|
|
||||||
>
|
>
|
||||||
<Text fw={500} size="lg" mb="sm">
|
{/* Chevron icon to collapse/expand the step */}
|
||||||
{shouldShowNumber ? `${stepNumber}. ` : ''}{title}
|
<Flex
|
||||||
</Text>
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
mb="sm"
|
||||||
|
style={{
|
||||||
|
cursor: onCollapsedClick ? 'pointer' : 'default'
|
||||||
|
}}
|
||||||
|
onClick={onCollapsedClick}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap="sm">
|
||||||
|
{shouldShowNumber && (
|
||||||
|
<Text fw={500} size="lg" c="dimmed">
|
||||||
|
{stepNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text fw={500} size="lg">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRightIcon style={{
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
color: 'var(--mantine-color-dimmed)',
|
||||||
|
opacity: onCollapsedClick ? 1 : 0.5
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<ExpandMoreIcon style={{
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
color: 'var(--mantine-color-dimmed)',
|
||||||
|
opacity: onCollapsedClick ? 1 : 0.5
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<Box>
|
<Box>
|
||||||
@ -98,7 +129,7 @@ export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
React.Children.forEach(children, (child) => {
|
React.Children.forEach(children, (child) => {
|
||||||
if (React.isValidElement(child) && child.type === ToolStep) {
|
if (React.isValidElement(child) && child.type === ToolStep) {
|
||||||
const isVisible = child.props.isVisible !== false;
|
const isVisible = (child.props as ToolStepProps).isVisible !== false;
|
||||||
if (isVisible) count++;
|
if (isVisible) count++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
372
frontend/src/hooks/tools/ocr/useOCROperation.ts
Normal file
372
frontend/src/hooks/tools/ocr/useOCROperation.ts
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFileContext } from '../../../contexts/FileContext';
|
||||||
|
import { FileOperation } from '../../../types/fileContext';
|
||||||
|
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
|
||||||
|
|
||||||
|
//Extract files from a ZIP blob
|
||||||
|
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
|
||||||
|
const JSZip = await import('jszip');
|
||||||
|
const zip = new JSZip.default();
|
||||||
|
|
||||||
|
const arrayBuffer = await zipBlob.arrayBuffer();
|
||||||
|
const zipContent = await zip.loadAsync(arrayBuffer);
|
||||||
|
|
||||||
|
const extractedFiles: File[] = [];
|
||||||
|
|
||||||
|
for (const [filename, file] of Object.entries(zipContent.files)) {
|
||||||
|
if (!file.dir) {
|
||||||
|
const content = await file.async('blob');
|
||||||
|
const extractedFile = new File([content], filename, { type: getMimeType(filename) });
|
||||||
|
extractedFiles.push(extractedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Get MIME type based on file extension
|
||||||
|
function getMimeType(filename: string): string {
|
||||||
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
|
switch (ext) {
|
||||||
|
case 'pdf':
|
||||||
|
return 'application/pdf';
|
||||||
|
case 'txt':
|
||||||
|
return 'text/plain';
|
||||||
|
case 'zip':
|
||||||
|
return 'application/zip';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OCROperationHook {
|
||||||
|
files: File[];
|
||||||
|
thumbnails: string[];
|
||||||
|
downloadUrl: string | null;
|
||||||
|
downloadFilename: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isGeneratingThumbnails: boolean;
|
||||||
|
status: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
executeOperation: (parameters: OCRParameters, selectedFiles: File[]) => Promise<void>;
|
||||||
|
resetResults: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOCROperation = (): OCROperationHook => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
recordOperation,
|
||||||
|
markOperationApplied,
|
||||||
|
markOperationFailed,
|
||||||
|
addFiles
|
||||||
|
} = useFileContext();
|
||||||
|
|
||||||
|
// Internal state management
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||||
|
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
|
const [downloadFilename, setDownloadFilename] = useState<string>('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Track blob URLs for cleanup
|
||||||
|
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const cleanupBlobUrls = useCallback(() => {
|
||||||
|
blobUrls.forEach(url => {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to revoke blob URL:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setBlobUrls([]);
|
||||||
|
}, [blobUrls]);
|
||||||
|
|
||||||
|
const buildFormData = useCallback((
|
||||||
|
parameters: OCRParameters,
|
||||||
|
file: File
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Add the file
|
||||||
|
formData.append('fileInput', file);
|
||||||
|
|
||||||
|
// Add languages as multiple parameters with same name (like checkboxes)
|
||||||
|
parameters.languages.forEach(lang => {
|
||||||
|
formData.append('languages', lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add other parameters
|
||||||
|
formData.append('ocrType', parameters.ocrType);
|
||||||
|
formData.append('ocrRenderType', parameters.ocrRenderType);
|
||||||
|
|
||||||
|
// Handle additional options - convert array to individual boolean parameters
|
||||||
|
formData.append('sidecar', parameters.additionalOptions.includes('sidecar').toString());
|
||||||
|
formData.append('deskew', parameters.additionalOptions.includes('deskew').toString());
|
||||||
|
formData.append('clean', parameters.additionalOptions.includes('clean').toString());
|
||||||
|
formData.append('cleanFinal', parameters.additionalOptions.includes('cleanFinal').toString());
|
||||||
|
formData.append('removeImagesAfter', parameters.additionalOptions.includes('removeImagesAfter').toString());
|
||||||
|
|
||||||
|
const endpoint = '/api/v1/misc/ocr-pdf';
|
||||||
|
|
||||||
|
return { formData, endpoint };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createOperation = useCallback((
|
||||||
|
parameters: OCRParameters,
|
||||||
|
selectedFiles: File[]
|
||||||
|
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||||
|
const operationId = `ocr-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const fileId = selectedFiles.map(f => f.name).join(',');
|
||||||
|
|
||||||
|
const operation: FileOperation = {
|
||||||
|
id: operationId,
|
||||||
|
type: 'ocr',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
fileIds: selectedFiles.map(f => f.name),
|
||||||
|
status: 'pending',
|
||||||
|
metadata: {
|
||||||
|
originalFileName: selectedFiles[0]?.name,
|
||||||
|
parameters: {
|
||||||
|
languages: parameters.languages,
|
||||||
|
ocrType: parameters.ocrType,
|
||||||
|
ocrRenderType: parameters.ocrRenderType,
|
||||||
|
additionalOptions: parameters.additionalOptions,
|
||||||
|
},
|
||||||
|
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { operation, operationId, fileId };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const executeOperation = useCallback(async (
|
||||||
|
parameters: OCRParameters,
|
||||||
|
selectedFiles: File[]
|
||||||
|
) => {
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
setStatus(t("noFileSelected") || "No file selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameters.languages.length === 0) {
|
||||||
|
setErrorMessage('Please select at least one language for OCR processing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles = selectedFiles.filter(file => file.size > 0);
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
setErrorMessage('No valid files to process. All selected files are empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length < selectedFiles.length) {
|
||||||
|
console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
||||||
|
|
||||||
|
recordOperation(fileId, operation);
|
||||||
|
|
||||||
|
setStatus(t("loading") || "Loading...");
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setFiles([]);
|
||||||
|
setThumbnails([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processedFiles: File[] = [];
|
||||||
|
const failedFiles: string[] = [];
|
||||||
|
|
||||||
|
// OCR typically processes one file at a time
|
||||||
|
for (let i = 0; i < validFiles.length; i++) {
|
||||||
|
const file = validFiles[i];
|
||||||
|
setStatus(`Processing OCR for ${file.name} (${i + 1}/${validFiles.length})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { formData, endpoint } = buildFormData(parameters, file);
|
||||||
|
const response = await axios.post(endpoint, formData, {
|
||||||
|
responseType: "blob",
|
||||||
|
timeout: 300000 // 5 minute timeout for OCR
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for HTTP errors
|
||||||
|
if (response.status >= 400) {
|
||||||
|
// Try to read error response as text
|
||||||
|
const errorText = await response.data.text();
|
||||||
|
throw new Error(`OCR service HTTP error ${response.status}: ${errorText.substring(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate response
|
||||||
|
if (!response.data || response.data.size === 0) {
|
||||||
|
throw new Error('Empty response from OCR service');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers['content-type'] || 'application/pdf';
|
||||||
|
|
||||||
|
// Check if response is actually a PDF by examining the first few bytes
|
||||||
|
const arrayBuffer = await response.data.arrayBuffer();
|
||||||
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
const header = new TextDecoder().decode(uint8Array.slice(0, 4));
|
||||||
|
|
||||||
|
// Check if it's a ZIP file (OCR service returns ZIP when sidecar is enabled or for multi-file results)
|
||||||
|
if (header.startsWith('PK')) {
|
||||||
|
try {
|
||||||
|
// Extract ZIP file contents
|
||||||
|
const zipFiles = await extractZipFile(response.data);
|
||||||
|
|
||||||
|
// Add extracted files to processed files
|
||||||
|
processedFiles.push(...zipFiles);
|
||||||
|
} catch (extractError) {
|
||||||
|
// Fallback to treating as single ZIP file
|
||||||
|
const blob = new Blob([response.data], { type: 'application/zip' });
|
||||||
|
const processedFile = new File([blob], `ocr_${file.name}.zip`, { type: 'application/zip' });
|
||||||
|
processedFiles.push(processedFile);
|
||||||
|
}
|
||||||
|
continue; // Skip the PDF validation for ZIP files
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!header.startsWith('%PDF')) {
|
||||||
|
// Check if it's an error response
|
||||||
|
const text = new TextDecoder().decode(uint8Array.slice(0, 500));
|
||||||
|
|
||||||
|
if (text.includes('error') || text.includes('Error') || text.includes('exception') || text.includes('html')) {
|
||||||
|
// Check for specific OCR tool unavailable error
|
||||||
|
if (text.includes('OCR tools') && text.includes('not installed')) {
|
||||||
|
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
|
||||||
|
}
|
||||||
|
throw new Error(`OCR service error: ${text.substring(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an HTML error page
|
||||||
|
if (text.includes('<html') || text.includes('<!DOCTYPE')) {
|
||||||
|
// Try to extract error message from HTML
|
||||||
|
const errorMatch = text.match(/<title[^>]*>([^<]+)<\/title>/i) ||
|
||||||
|
text.match(/<h1[^>]*>([^<]+)<\/h1>/i) ||
|
||||||
|
text.match(/<body[^>]*>([^<]+)<\/body>/i);
|
||||||
|
const errorMessage = errorMatch ? errorMatch[1].trim() : 'Unknown error';
|
||||||
|
throw new Error(`OCR service error: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Response is not a valid PDF file. Header: "${header}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], { type: contentType });
|
||||||
|
const processedFile = new File([blob], `ocr_${file.name}`, { type: contentType });
|
||||||
|
|
||||||
|
processedFiles.push(processedFile);
|
||||||
|
} catch (fileError) {
|
||||||
|
const errorMessage = fileError instanceof Error ? fileError.message : 'Unknown error';
|
||||||
|
failedFiles.push(`${file.name} (${errorMessage})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedFiles.length > 0 && processedFiles.length === 0) {
|
||||||
|
throw new Error(`Failed to process OCR for all files: ${failedFiles.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedFiles.length > 0) {
|
||||||
|
setStatus(`Processed ${processedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
const hasPdfFiles = processedFiles.some(file => file.name.endsWith('.pdf'));
|
||||||
|
const hasTxtFiles = processedFiles.some(file => file.name.endsWith('.txt'));
|
||||||
|
let statusMessage = `OCR completed successfully for ${processedFiles.length} file(s)`;
|
||||||
|
|
||||||
|
if (hasPdfFiles && hasTxtFiles) {
|
||||||
|
statusMessage += ' (Extracted PDF and text files)';
|
||||||
|
} else if (hasPdfFiles) {
|
||||||
|
statusMessage += ' (Extracted PDF files)';
|
||||||
|
} else if (hasTxtFiles) {
|
||||||
|
statusMessage += ' (Extracted text files)';
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(statusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles(processedFiles);
|
||||||
|
setIsGeneratingThumbnails(true);
|
||||||
|
|
||||||
|
await addFiles(processedFiles);
|
||||||
|
|
||||||
|
// Cleanup old blob URLs
|
||||||
|
cleanupBlobUrls();
|
||||||
|
|
||||||
|
// Create download URL - for multiple files, we'll create a new ZIP
|
||||||
|
if (processedFiles.length === 1) {
|
||||||
|
const url = window.URL.createObjectURL(processedFiles[0]);
|
||||||
|
setDownloadUrl(url);
|
||||||
|
setBlobUrls([url]);
|
||||||
|
setDownloadFilename(processedFiles[0].name);
|
||||||
|
} else {
|
||||||
|
// For multiple files, create a new ZIP containing all extracted files
|
||||||
|
try {
|
||||||
|
const JSZip = await import('jszip');
|
||||||
|
const zip = new JSZip.default();
|
||||||
|
|
||||||
|
for (const file of processedFiles) {
|
||||||
|
zip.file(file.name, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const url = window.URL.createObjectURL(zipBlob);
|
||||||
|
setDownloadUrl(url);
|
||||||
|
setBlobUrls([url]);
|
||||||
|
setDownloadFilename(`ocr_extracted_files.zip`);
|
||||||
|
} catch (zipError) {
|
||||||
|
// Fallback to first file
|
||||||
|
const url = window.URL.createObjectURL(processedFiles[0]);
|
||||||
|
setDownloadUrl(url);
|
||||||
|
setBlobUrls([url]);
|
||||||
|
setDownloadFilename(processedFiles[0].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markOperationApplied(fileId, operationId);
|
||||||
|
setIsGeneratingThumbnails(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OCR operation error:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'OCR operation failed';
|
||||||
|
setErrorMessage(errorMessage);
|
||||||
|
setStatus('');
|
||||||
|
markOperationFailed(fileId, operationId, errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [buildFormData, createOperation, recordOperation, addFiles, cleanupBlobUrls, markOperationApplied, markOperationFailed, t]);
|
||||||
|
|
||||||
|
const resetResults = useCallback(() => {
|
||||||
|
setFiles([]);
|
||||||
|
setThumbnails([]);
|
||||||
|
setDownloadUrl(null);
|
||||||
|
setDownloadFilename('');
|
||||||
|
setStatus('');
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsGeneratingThumbnails(false);
|
||||||
|
cleanupBlobUrls();
|
||||||
|
}, [cleanupBlobUrls]);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
thumbnails,
|
||||||
|
downloadUrl,
|
||||||
|
downloadFilename,
|
||||||
|
isLoading,
|
||||||
|
isGeneratingThumbnails,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
executeOperation,
|
||||||
|
resetResults,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
};
|
43
frontend/src/hooks/tools/ocr/useOCRParameters.ts
Normal file
43
frontend/src/hooks/tools/ocr/useOCRParameters.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
|
||||||
|
|
||||||
|
export interface OCRParametersHook {
|
||||||
|
parameters: OCRParameters;
|
||||||
|
updateParameter: (key: keyof OCRParameters, value: any) => void;
|
||||||
|
resetParameters: () => void;
|
||||||
|
validateParameters: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultParameters: OCRParameters = {
|
||||||
|
languages: [],
|
||||||
|
ocrType: 'skip-text',
|
||||||
|
ocrRenderType: 'hocr',
|
||||||
|
additionalOptions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOCRParameters = (): OCRParametersHook => {
|
||||||
|
const [parameters, setParameters] = useState<OCRParameters>(defaultParameters);
|
||||||
|
|
||||||
|
const updateParameter = (key: keyof OCRParameters, value: any) => {
|
||||||
|
setParameters(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetParameters = () => {
|
||||||
|
setParameters(defaultParameters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateParameters = () => {
|
||||||
|
// At minimum, we need at least one language selected
|
||||||
|
return parameters.languages.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
parameters,
|
||||||
|
updateParameter,
|
||||||
|
resetParameters,
|
||||||
|
validateParameters,
|
||||||
|
};
|
||||||
|
};
|
@ -35,7 +35,6 @@ export function useEndpointEnabled(endpoint: string): {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error(`Failed to check endpoint ${endpoint}:`, err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -80,6 +79,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
|
|
||||||
// Use batch API for efficiency
|
// Use batch API for efficiency
|
||||||
const endpointsParam = endpoints.join(',');
|
const endpointsParam = endpoints.join(',');
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -105,6 +105,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const endpointsKey = endpoints.join(',');
|
||||||
fetchAllEndpointStatuses();
|
fetchAllEndpointStatuses();
|
||||||
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
||||||
|
|
||||||
|
@ -64,6 +64,17 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|||||||
description: "Open API documentation",
|
description: "Open API documentation",
|
||||||
endpoints: ["swagger-ui"]
|
endpoints: ["swagger-ui"]
|
||||||
},
|
},
|
||||||
|
ocr: {
|
||||||
|
id: "ocr",
|
||||||
|
icon: <span className="material-symbols-rounded font-size-20">
|
||||||
|
quick_reference_all
|
||||||
|
</span>,
|
||||||
|
component: React.lazy(() => import("../tools/OCR")),
|
||||||
|
maxFiles: -1,
|
||||||
|
category: "utility",
|
||||||
|
description: "Extract text from images using OCR",
|
||||||
|
endpoints: ["ocr-pdf"]
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -167,6 +167,39 @@ export const mantineTheme = createTheme({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
MultiSelect: {
|
||||||
|
styles: {
|
||||||
|
input: {
|
||||||
|
backgroundColor: 'var(--bg-surface)',
|
||||||
|
borderColor: 'var(--border-default)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
'&:focus': {
|
||||||
|
borderColor: 'var(--color-primary-500)',
|
||||||
|
boxShadow: '0 0 0 1px var(--color-primary-500)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontWeight: 'var(--font-weight-medium)',
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
backgroundColor: 'var(--bg-surface)',
|
||||||
|
borderColor: 'var(--border-subtle)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'var(--hover-bg)',
|
||||||
|
},
|
||||||
|
'&[data-selected]': {
|
||||||
|
backgroundColor: 'var(--color-primary-100)',
|
||||||
|
color: 'var(--color-primary-900)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
Checkbox: {
|
Checkbox: {
|
||||||
styles: {
|
styles: {
|
||||||
input: {
|
input: {
|
||||||
|
215
frontend/src/tools/OCR.tsx
Normal file
215
frontend/src/tools/OCR.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button, Stack, Text, Box } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
|
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||||
|
import OperationButton from "../components/tools/shared/OperationButton";
|
||||||
|
import ErrorNotification from "../components/tools/shared/ErrorNotification";
|
||||||
|
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
|
||||||
|
import ResultsPreview from "../components/tools/shared/ResultsPreview";
|
||||||
|
|
||||||
|
import OCRSettings from "../components/tools/ocr/OCRSettings";
|
||||||
|
import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
|
||||||
|
|
||||||
|
import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters";
|
||||||
|
import { useOCROperation } from "../hooks/tools/ocr/useOCROperation";
|
||||||
|
import { BaseToolProps } from "../types/tool";
|
||||||
|
|
||||||
|
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { setCurrentMode } = useFileContext();
|
||||||
|
const { selectedFiles } = useToolFileSelection();
|
||||||
|
|
||||||
|
const ocrParams = useOCRParameters();
|
||||||
|
const ocrOperation = useOCROperation();
|
||||||
|
|
||||||
|
// Step expansion state management
|
||||||
|
const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files');
|
||||||
|
|
||||||
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("ocr-pdf");
|
||||||
|
|
||||||
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
const hasResults = ocrOperation.files.length > 0 || ocrOperation.downloadUrl !== null;
|
||||||
|
const hasValidSettings = ocrParams.validateParameters();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ocrOperation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}, [ocrParams.parameters, selectedFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFiles.length > 0 && expandedStep === 'files') {
|
||||||
|
setExpandedStep('settings');
|
||||||
|
}
|
||||||
|
}, [selectedFiles.length, expandedStep]);
|
||||||
|
|
||||||
|
// Collapse all steps when results appear
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasResults) {
|
||||||
|
setExpandedStep(null);
|
||||||
|
}
|
||||||
|
}, [hasResults]);
|
||||||
|
|
||||||
|
const handleOCR = async () => {
|
||||||
|
try {
|
||||||
|
await ocrOperation.executeOperation(
|
||||||
|
ocrParams.parameters,
|
||||||
|
selectedFiles
|
||||||
|
);
|
||||||
|
if (ocrOperation.files && onComplete) {
|
||||||
|
onComplete(ocrOperation.files);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (onError) {
|
||||||
|
onError(error instanceof Error ? error.message : 'OCR operation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThumbnailClick = (file: File) => {
|
||||||
|
onPreviewFile?.(file);
|
||||||
|
sessionStorage.setItem('previousMode', 'ocr');
|
||||||
|
setCurrentMode('viewer');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Step visibility and collapse logic
|
||||||
|
const filesVisible = true;
|
||||||
|
const settingsVisible = true;
|
||||||
|
const resultsVisible = hasResults;
|
||||||
|
|
||||||
|
const filesCollapsed = expandedStep !== 'files';
|
||||||
|
const settingsCollapsed = expandedStep !== 'settings';
|
||||||
|
|
||||||
|
const previewResults = useMemo(() =>
|
||||||
|
ocrOperation.files?.map((file: File, index: number) => ({
|
||||||
|
file,
|
||||||
|
thumbnail: ocrOperation.thumbnails[index]
|
||||||
|
})) || [],
|
||||||
|
[ocrOperation.files, ocrOperation.thumbnails]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolStepContainer>
|
||||||
|
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
|
||||||
|
{/* Files Step */}
|
||||||
|
<ToolStep
|
||||||
|
title="Files"
|
||||||
|
isVisible={filesVisible}
|
||||||
|
isCollapsed={hasFiles ? filesCollapsed : false}
|
||||||
|
isCompleted={hasFiles}
|
||||||
|
onCollapsedClick={undefined}
|
||||||
|
completedMessage={hasFiles && filesCollapsed ?
|
||||||
|
selectedFiles.length === 1
|
||||||
|
? `Selected: ${selectedFiles[0].name}`
|
||||||
|
: `Selected: ${selectedFiles.length} files`
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<FileStatusIndicator
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
placeholder="Select a PDF file in the main view to get started"
|
||||||
|
/>
|
||||||
|
</ToolStep>
|
||||||
|
|
||||||
|
{/* Settings Step */}
|
||||||
|
<ToolStep
|
||||||
|
title="Settings"
|
||||||
|
isVisible={settingsVisible}
|
||||||
|
isCollapsed={settingsCollapsed}
|
||||||
|
isCompleted={hasFiles && hasValidSettings}
|
||||||
|
onCollapsedClick={() => {
|
||||||
|
if (!hasFiles) return; // Only allow if files are selected
|
||||||
|
setExpandedStep(expandedStep === 'settings' ? null : 'settings');
|
||||||
|
}}
|
||||||
|
completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<OCRSettings
|
||||||
|
parameters={ocrParams.parameters}
|
||||||
|
onParameterChange={ocrParams.updateParameter}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</ToolStep>
|
||||||
|
|
||||||
|
{/* Advanced Step */}
|
||||||
|
<ToolStep
|
||||||
|
title="Advanced"
|
||||||
|
isVisible={true}
|
||||||
|
isCollapsed={expandedStep !== 'advanced'}
|
||||||
|
isCompleted={hasFiles && hasResults}
|
||||||
|
onCollapsedClick={() => {
|
||||||
|
if (!hasFiles) return; // Only allow if files are selected
|
||||||
|
setExpandedStep(expandedStep === 'advanced' ? null : 'advanced');
|
||||||
|
}}
|
||||||
|
completedMessage={hasFiles && hasResults && expandedStep !== 'advanced' ? "OCR processing completed" : undefined}
|
||||||
|
>
|
||||||
|
<AdvancedOCRSettings
|
||||||
|
advancedOptions={ocrParams.parameters.additionalOptions}
|
||||||
|
ocrRenderType={ocrParams.parameters.ocrRenderType}
|
||||||
|
onParameterChange={ocrParams.updateParameter}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
</ToolStep>
|
||||||
|
|
||||||
|
{/* Process Button - Available after all configuration */}
|
||||||
|
{hasValidSettings && !hasResults && (
|
||||||
|
<Box mt="md">
|
||||||
|
<OperationButton
|
||||||
|
onClick={handleOCR}
|
||||||
|
isLoading={ocrOperation.isLoading}
|
||||||
|
disabled={!ocrParams.validateParameters() || !hasFiles || !endpointEnabled}
|
||||||
|
loadingText={t("loading")}
|
||||||
|
submitText="Process OCR and Review"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Step */}
|
||||||
|
<ToolStep
|
||||||
|
title="Results"
|
||||||
|
isVisible={resultsVisible}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{ocrOperation.status && (
|
||||||
|
<Text size="sm" c="dimmed">{ocrOperation.status}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ErrorNotification
|
||||||
|
error={ocrOperation.errorMessage}
|
||||||
|
onClose={ocrOperation.clearError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ocrOperation.downloadUrl && (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={ocrOperation.downloadUrl}
|
||||||
|
download={ocrOperation.downloadFilename}
|
||||||
|
leftSection={<DownloadIcon />}
|
||||||
|
color="green"
|
||||||
|
fullWidth
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
{t("download", "Download")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResultsPreview
|
||||||
|
files={previewResults}
|
||||||
|
onFileClick={handleThumbnailClick}
|
||||||
|
isGeneratingThumbnails={ocrOperation.isGeneratingThumbnails}
|
||||||
|
title="OCR Results"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</ToolStep>
|
||||||
|
</Stack>
|
||||||
|
</ToolStepContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OCR;
|
@ -5,15 +5,13 @@
|
|||||||
import { ProcessedFile } from './processing';
|
import { ProcessedFile } from './processing';
|
||||||
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
|
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
|
||||||
|
|
||||||
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress';
|
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr';
|
||||||
|
|
||||||
// Legacy types for backward compatibility during transition
|
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr';
|
||||||
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';
|
|
||||||
export type ToolType = 'merge' | 'split' | 'compress' | null;
|
|
||||||
|
|
||||||
export interface FileOperation {
|
export interface FileOperation {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload';
|
type: OperationType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
fileIds: string[];
|
fileIds: string[];
|
||||||
status: 'pending' | 'applied' | 'failed';
|
status: 'pending' | 'applied' | 'failed';
|
||||||
@ -56,9 +54,6 @@ export interface FileContextState {
|
|||||||
|
|
||||||
// Current navigation state
|
// Current navigation state
|
||||||
currentMode: ModeType;
|
currentMode: ModeType;
|
||||||
// Legacy fields for backward compatibility
|
|
||||||
currentView: ViewType;
|
|
||||||
currentTool: ToolType;
|
|
||||||
|
|
||||||
// Edit history and state
|
// Edit history and state
|
||||||
fileEditHistory: Map<string, FileEditHistory>;
|
fileEditHistory: Map<string, FileEditHistory>;
|
||||||
@ -97,10 +92,6 @@ export interface FileContextActions {
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
setCurrentMode: (mode: ModeType) => void;
|
setCurrentMode: (mode: ModeType) => void;
|
||||||
// Legacy navigation functions for backward compatibility
|
|
||||||
setCurrentView: (view: ViewType) => void;
|
|
||||||
setCurrentTool: (tool: ToolType) => void;
|
|
||||||
|
|
||||||
// Selection management
|
// Selection management
|
||||||
setSelectedFiles: (fileIds: string[]) => void;
|
setSelectedFiles: (fileIds: string[]) => void;
|
||||||
setSelectedPages: (pageNumbers: number[]) => void;
|
setSelectedPages: (pageNumbers: number[]) => void;
|
||||||
@ -168,9 +159,6 @@ export interface WithFileContext {
|
|||||||
// URL parameter types for deep linking
|
// URL parameter types for deep linking
|
||||||
export interface FileContextUrlParams {
|
export interface FileContextUrlParams {
|
||||||
mode?: ModeType;
|
mode?: ModeType;
|
||||||
// Legacy parameters for backward compatibility
|
|
||||||
view?: ViewType;
|
|
||||||
tool?: ToolType;
|
|
||||||
fileIds?: string[];
|
fileIds?: string[];
|
||||||
pageIds?: string[];
|
pageIds?: string[];
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
1020
frontend/src/utils/languageMapping.ts
Normal file
1020
frontend/src/utils/languageMapping.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user