mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
Tools/ocr/v2 (#4055)
# Description of Changes - Added the OCR tool - Added language mappings file to map selected browser language -> OCR language and OCR language codes -> english display values. TODO: Use the translation function to translate the languages rather than mapping them to english be default - Added chevron icons to tool step to show expand and collapsed state more visibly - Added a re-usable dropdown picker with a footer component --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
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 */
|
||||
[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) {
|
||||
@@ -52,11 +52,11 @@
|
||||
}
|
||||
|
||||
[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) {
|
||||
border-right-color: var(--mantine-color-dark-4);
|
||||
border-right-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
|
||||
/* Responsive text visibility */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { supportedLanguages } from '../../i18n';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
@@ -7,8 +7,6 @@ import styles from './LanguageSelector.module.css';
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
@@ -102,10 +100,10 @@ const LanguageSelector = () => {
|
||||
styles={{
|
||||
root: {
|
||||
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)',
|
||||
'&: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: {
|
||||
@@ -125,7 +123,8 @@ const LanguageSelector = () => {
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
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">
|
||||
@@ -145,6 +144,7 @@ const LanguageSelector = () => {
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={(event) => handleLanguageChange(option.value, event)}
|
||||
data-selected={option.value === i18n.language}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: '4px',
|
||||
@@ -153,21 +153,17 @@ const LanguageSelector = () => {
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
|
||||
) : 'transparent',
|
||||
color: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
|
||||
),
|
||||
backgroundColor: option.value === i18n.language
|
||||
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
||||
: 'transparent',
|
||||
color: option.value === i18n.language
|
||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
|
||||
),
|
||||
backgroundColor: option.value === i18n.language
|
||||
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
|
||||
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}
|
||||
@@ -197,7 +193,7 @@ const LanguageSelector = () => {
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: theme.colors.blue[4],
|
||||
backgroundColor: 'var(--mantine-color-blue-4)',
|
||||
opacity: 0.6,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
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 { 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 {
|
||||
visibleStepCount: number;
|
||||
@@ -33,12 +35,13 @@ const ToolStep = ({
|
||||
}: ToolStepProps) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const parent = useContext(ToolStepContext);
|
||||
|
||||
// Auto-detect if we should show numbers based on sibling count
|
||||
const shouldShowNumber = useMemo(() => {
|
||||
if (showNumber !== undefined) return showNumber;
|
||||
const parent = useContext(ToolStepContext);
|
||||
return parent ? parent.visibleStepCount >= 3 : false;
|
||||
}, [showNumber]);
|
||||
}, [showNumber, parent]);
|
||||
|
||||
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
|
||||
|
||||
@@ -47,15 +50,45 @@ const ToolStep = ({
|
||||
p="md"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: isCollapsed && onCollapsedClick ? 'pointer' : 'default',
|
||||
opacity: isCollapsed ? 0.8 : 1,
|
||||
transition: 'opacity 0.2s ease'
|
||||
}}
|
||||
onClick={isCollapsed && onCollapsedClick ? onCollapsedClick : undefined}
|
||||
>
|
||||
<Text fw={500} size="lg" mb="sm">
|
||||
{shouldShowNumber ? `${stepNumber}. ` : ''}{title}
|
||||
</Text>
|
||||
{/* Chevron icon to collapse/expand the step */}
|
||||
<Flex
|
||||
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 ? (
|
||||
<Box>
|
||||
@@ -96,7 +129,7 @@ export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
|
||||
let count = 0;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (React.isValidElement(child) && child.type === ToolStep) {
|
||||
const isVisible = child.props.isVisible !== false;
|
||||
const isVisible = (child.props as ToolStepProps).isVisible !== false;
|
||||
if (isVisible) count++;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user