This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
@@ -0,0 +1,956 @@
import React, { useRef, useCallback, useState, useEffect } from 'react';
import {
Box,
Button,
HStack,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
FormControl,
FormLabel,
FormHelperText,
Input,
Text,
SimpleGrid,
useToast,
VStack,
useColorModeValue,
ButtonGroup,
IconButton,
Tooltip,
} from '@chakra-ui/react';
import ReactQuill from 'react-quill';
import ReactCrop, { Crop } from 'react-image-crop';
import DOMPurify from 'dompurify';
import 'react-quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
import '../../styles/custom-editor.css';
import {
Image as ImageIcon, Code, Type, Trash2, AlignLeft, AlignCenter, AlignRight,
RotateCw, RotateCcw, FlipHorizontal, FlipVertical, Sun, Droplets, Eye,
Sparkles, Contrast, ZoomIn, ZoomOut, Move, Maximize2, Settings,
Circle, Square, X, Check, Filter
} from 'lucide-react';
interface ImageFilters {
brightness: number;
contrast: number;
saturation: number;
blur: number;
grayscale: number;
sepia: number;
hueRotate: number;
rotation: number;
flipH: boolean;
flipV: boolean;
}
interface CustomRichEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
height?: string;
readOnly?: boolean;
onImageUpload?: (file: File) => Promise<{ url: string }>;
showImageResize?: boolean;
toolbar?: 'full' | 'basic' | 'minimal';
}
const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
value,
onChange,
placeholder = 'Začněte psát...',
height = '400px',
readOnly = false,
onImageUpload,
showImageResize = true,
toolbar = 'full',
}) => {
const toast = useToast();
const quillRef = useRef<ReactQuill | null>(null);
const [editorMode, setEditorMode] = useState<'rich' | 'html'>('rich');
// Crop modal state
const [cropOpen, setCropOpen] = useState(false);
const [cropSrc, setCropSrc] = useState<string | null>(null);
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
const [cropQuality, setCropQuality] = useState<number>(85);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1500);
const imgRef = useRef<HTMLImageElement | null>(null);
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgColor = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const toolbarBg = useColorModeValue('white', 'gray.800');
const toolbarBorder = useColorModeValue('gray.200', 'gray.700');
// Image editing state
const [selectedImageElement, setSelectedImageElement] = useState<HTMLImageElement | null>(null);
const [imageFilters, setImageFilters] = useState<ImageFilters>({
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0,
grayscale: 0,
sepia: 0,
hueRotate: 0,
rotation: 0,
flipH: false,
flipV: false,
});
const [showImageToolbar, setShowImageToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
// Define toolbar configurations
const toolbarConfigs = {
full: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image', 'video'],
['blockquote', 'code-block'],
['clean'],
],
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image'],
['clean'],
],
minimal: [
['bold', 'italic', 'underline'],
[{ list: 'bullet' }],
['link'],
['clean'],
],
};
const getToolbarConfig = () => {
return toolbarConfigs[toolbar] || toolbarConfigs.full;
};
// Image upload handler
const handleImageUpload = useCallback(() => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.onchange = async () => {
const file = (input.files || [])[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setCropSrc(reader.result as string);
setCropOpen(true);
};
reader.readAsDataURL(file);
};
input.click();
}, []);
// Get cropped blob
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
const canvas = document.createElement('canvas');
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
let outputWidth = Math.max(1, Math.round(cropPixels.width * scaleX));
let outputHeight = Math.max(1, Math.round(cropPixels.height * scaleY));
if (outputWidth > cropMaxWidth) {
const scale = cropMaxWidth / outputWidth;
outputWidth = cropMaxWidth;
outputHeight = Math.round(outputHeight * scale);
}
canvas.width = outputWidth;
canvas.height = outputHeight;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) throw new Error('Canvas not supported');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(
image,
Math.round(cropPixels.x * scaleX),
Math.round(cropPixels.y * scaleY),
Math.round(cropPixels.width * scaleX),
Math.round(cropPixels.height * scaleY),
0,
0,
outputWidth,
outputHeight
);
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob as Blob), 'image/jpeg', cropQuality / 100);
});
};
// Confirm crop and insert
const confirmCropAndInsert = async () => {
try {
if (!imgRef.current) {
toast({ title: 'Chyba', description: 'Obrázek není načten', status: 'error' });
return;
}
if (!crop.width || !crop.height || crop.width <= 0 || crop.height <= 0) {
toast({ title: 'Chyba', description: 'Vyberte oblast k oříznutí', status: 'warning' });
return;
}
const img = imgRef.current;
const percToPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
const cropPx = {
x: Math.max(0, percToPx(crop.x || 0, img.width)),
y: Math.max(0, percToPx(crop.y || 0, img.height)),
width: Math.min(img.width, percToPx(crop.width || img.width, img.width)),
height: Math.min(img.height, percToPx(crop.height || img.height, img.height)),
};
if (cropPx.x + cropPx.width > img.width) {
cropPx.width = img.width - cropPx.x;
}
if (cropPx.y + cropPx.height > img.height) {
cropPx.height = img.height - cropPx.y;
}
const blob = await getCroppedBlob(img, cropPx);
const file = new File([blob], 'cropped-image.jpg', { type: 'image/jpeg' });
if (onImageUpload) {
toast({ title: 'Nahrávám obrázek...', status: 'info', duration: 2000 });
const res = await onImageUpload(file);
if (!res.url) {
throw new Error('Upload failed - no URL returned');
}
const quill = quillRef.current?.getEditor();
if (quill) {
const range = quill.getSelection(true);
const index = range ? range.index : quill.getLength();
quill.insertEmbed(index, 'image', res.url, 'user');
quill.setSelection(index + 1, 0);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
}
}
} catch (e: any) {
console.error('Crop and insert error:', e);
toast({ title: 'Zpracování obrázku selhalo', description: e?.message || 'Chyba', status: 'error' });
} finally {
setCropOpen(false);
setCropSrc(null);
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
setCropQuality(85);
setCropMaxWidth(1500);
}
};
// Make images draggable and resizable
useEffect(() => {
const editor = quillRef.current?.getEditor();
if (!editor || readOnly) return;
let selectedImage: HTMLImageElement | null = null;
let resizeHandle: HTMLDivElement | null = null;
let isResizing = false;
let isDragging = false;
let startX = 0;
let startY = 0;
let startWidth = 0;
const createResizeHandle = (img: HTMLImageElement) => {
removeResizeHandle();
const handle = document.createElement('div');
handle.className = 'custom-image-resize-handle';
handle.style.cssText = `
position: absolute;
width: 14px;
height: 14px;
background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%);
border: 2px solid white;
border-radius: 50%;
cursor: nwse-resize;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.2s;
`;
const updateHandlePosition = () => {
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
handle.style.left = `${rect.right - editorRect.left - 7}px`;
handle.style.top = `${rect.bottom - editorRect.top - 7}px`;
};
updateHandlePosition();
editor.root.style.position = 'relative';
editor.root.appendChild(handle);
resizeHandle = handle;
handle.addEventListener('mouseenter', () => {
handle.style.transform = 'scale(1.2)';
});
handle.addEventListener('mouseleave', () => {
handle.style.transform = 'scale(1)';
});
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
isResizing = true;
startX = e.clientX;
startWidth = img.offsetWidth;
const onMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const deltaX = e.clientX - startX;
const newWidth = Math.max(50, startWidth + deltaX);
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
updateHandlePosition();
};
const onMouseUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
onChange(editor.root.innerHTML);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
return handle;
};
const removeResizeHandle = () => {
if (resizeHandle && resizeHandle.parentNode) {
resizeHandle.parentNode.removeChild(resizeHandle);
resizeHandle = null;
}
};
const selectImage = (img: HTMLImageElement) => {
if (selectedImage) {
selectedImage.style.outline = '';
selectedImage.style.cursor = '';
selectedImage.style.boxShadow = '';
}
selectedImage = img;
img.style.outline = '3px solid #3182ce';
img.style.cursor = 'move';
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
createResizeHandle(img);
// Set selected image state and load filters
setSelectedImageElement(img);
const filtersData = img.getAttribute('data-filters');
if (filtersData) {
try {
const savedFilters = JSON.parse(filtersData);
setImageFilters(savedFilters);
} catch {
// If parsing fails, use defaults
}
}
// Show toolbar and position it
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
setToolbarPosition({
top: rect.top - editorRect.top - 50,
left: rect.left - editorRect.left,
});
setShowImageToolbar(true);
};
const deselectImage = () => {
if (selectedImage) {
selectedImage.style.outline = '';
selectedImage.style.cursor = '';
selectedImage.style.boxShadow = '';
selectedImage = null;
}
removeResizeHandle();
setSelectedImageElement(null);
setShowImageToolbar(false);
};
const handleImageClick = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG') {
e.preventDefault();
e.stopPropagation();
selectImage(target as HTMLImageElement);
} else if (!target.classList.contains('custom-image-resize-handle')) {
deselectImage();
}
};
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG' && selectedImage === target) {
e.preventDefault();
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const onMouseMove = (e: MouseEvent) => {
if (!isDragging || !selectedImage) return;
const deltaX = e.clientX - startX;
if (Math.abs(deltaX) > 20) {
if (deltaX > 0) {
selectedImage.style.display = 'block';
selectedImage.style.marginLeft = 'auto';
selectedImage.style.marginRight = '0';
} else {
selectedImage.style.display = 'block';
selectedImage.style.marginLeft = '0';
selectedImage.style.marginRight = 'auto';
}
}
};
const onMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (selectedImage) {
onChange(editor.root.innerHTML);
}
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
};
// Delete selected image on Delete key
const handleKeyDown = (e: KeyboardEvent) => {
if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault();
selectedImage.remove();
deselectImage();
onChange(editor.root.innerHTML);
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}
};
editor.root.addEventListener('click', handleImageClick);
editor.root.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
editor.root.removeEventListener('click', handleImageClick);
editor.root.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
removeResizeHandle();
deselectImage();
};
}, [value, onChange, readOnly, toast]);
// Apply filters to selected image
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
const filterString = `
brightness(${filters.brightness}%)
contrast(${filters.contrast}%)
saturate(${filters.saturation}%)
blur(${filters.blur}px)
grayscale(${filters.grayscale}%)
sepia(${filters.sepia}%)
hue-rotate(${filters.hueRotate}deg)
`.trim();
const transform = `
rotate(${filters.rotation}deg)
scaleX(${filters.flipH ? -1 : 1})
scaleY(${filters.flipV ? -1 : 1})
`.trim();
img.style.filter = filterString;
img.style.transform = transform;
img.setAttribute('data-filters', JSON.stringify(filters));
}, []);
// Reset filters
const resetFilters = useCallback(() => {
const defaultFilters: ImageFilters = {
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0,
grayscale: 0,
sepia: 0,
hueRotate: 0,
rotation: 0,
flipH: false,
flipV: false,
};
setImageFilters(defaultFilters);
if (selectedImageElement) {
applyFiltersToImage(selectedImageElement, defaultFilters);
}
}, [selectedImageElement, applyFiltersToImage]);
// Update filter and apply to image
const updateFilter = useCallback((key: keyof ImageFilters, value: any) => {
setImageFilters(prev => {
const newFilters = { ...prev, [key]: value };
if (selectedImageElement) {
applyFiltersToImage(selectedImageElement, newFilters);
}
return newFilters;
});
}, [selectedImageElement, applyFiltersToImage]);
// Sanitize HTML on change
const handleChange = (content: string) => {
const cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters'],
});
onChange(cleaned);
};
return (
<Box>
{/* Editor Controls */}
{!readOnly && (
<HStack mb={2} spacing={2} justify="space-between" flexWrap="wrap">
<ButtonGroup size="sm" isAttached variant="outline">
<Button
leftIcon={<Type size={16} />}
variant={editorMode === 'rich' ? 'solid' : 'outline'}
colorScheme={editorMode === 'rich' ? 'blue' : 'gray'}
onClick={() => setEditorMode('rich')}
>
Editor
</Button>
<Button
leftIcon={<Code size={16} />}
variant={editorMode === 'html' ? 'solid' : 'outline'}
colorScheme={editorMode === 'html' ? 'blue' : 'gray'}
onClick={() => setEditorMode('html')}
>
HTML
</Button>
</ButtonGroup>
{editorMode === 'rich' && onImageUpload && (
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
)}
</HStack>
)}
{editorMode === 'rich' ? (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={bgColor}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
borderColor: borderColor,
bg: hoverBg,
},
'.ql-container': {
fontSize: '16px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
},
'.ql-editor': {
minHeight: height,
maxHeight: '70vh',
overflowY: 'auto',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
bg: 'gray.100',
},
'&::-webkit-scrollbar-thumb': {
bg: 'gray.400',
borderRadius: '4px',
},
img: {
cursor: 'pointer',
maxWidth: '100%',
height: 'auto',
display: 'block',
margin: '12px 0',
transition: 'all 0.2s ease',
borderRadius: '4px',
userSelect: 'none',
'&:hover': {
opacity: 0.95,
transform: 'scale(1.01)',
},
},
},
'.ql-editor.ql-blank::before': {
color: 'gray.400',
fontStyle: 'italic',
},
}}
>
<ReactQuill
theme="snow"
value={value}
onChange={handleChange}
readOnly={readOnly}
placeholder={placeholder}
ref={quillRef}
modules={{
toolbar: {
container: getToolbarConfig(),
handlers: {
image: onImageUpload ? handleImageUpload : undefined,
},
},
clipboard: {
matchVisual: false,
},
}}
/>
</Box>
) : (
<Box
as="textarea"
value={value}
onChange={(e: any) => onChange(e.target.value)}
fontFamily="mono"
fontSize="sm"
p={4}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={bgColor}
resize="vertical"
minH={height}
maxH="70vh"
width="100%"
/>
)}
{!readOnly && editorMode === 'rich' && (
<Text fontSize="xs" color="gray.500" mt={2}>
💡 Tip: Klikněte na obrázek pro výběr a úpravu. Používejte nástrojovou lištu pro filtry a transformace.
</Text>
)}
{/* Floating Image Editing Toolbar */}
{showImageToolbar && selectedImageElement && !readOnly && (
<Box
position="absolute"
top={`${toolbarPosition.top}px`}
left={`${toolbarPosition.left}px`}
bg={toolbarBg}
borderWidth="1px"
borderColor={toolbarBorder}
borderRadius="lg"
boxShadow="lg"
p={3}
zIndex={1500}
minW="320px"
maxW="400px"
>
<VStack align="stretch" spacing={3}>
{/* Toolbar Header */}
<HStack justify="space-between">
<HStack spacing={2}>
<Settings size={16} />
<Text fontWeight="bold" fontSize="sm">Úprava obrázku</Text>
</HStack>
<IconButton
aria-label="Close"
icon={<X size={16} />}
size="xs"
onClick={() => setShowImageToolbar(false)}
variant="ghost"
/>
</HStack>
{/* Transform Buttons */}
<HStack spacing={2} flexWrap="wrap">
<Tooltip label="Otočit doleva">
<IconButton
aria-label="Rotate left"
icon={<RotateCcw size={16} />}
size="sm"
onClick={() => updateFilter('rotation', (imageFilters.rotation - 90) % 360)}
colorScheme="blue"
variant="outline"
/>
</Tooltip>
<Tooltip label="Otočit doprava">
<IconButton
aria-label="Rotate right"
icon={<RotateCw size={16} />}
size="sm"
onClick={() => updateFilter('rotation', (imageFilters.rotation + 90) % 360)}
colorScheme="blue"
variant="outline"
/>
</Tooltip>
<Tooltip label="Převrátit horizontálně">
<IconButton
aria-label="Flip horizontal"
icon={<FlipHorizontal size={16} />}
size="sm"
onClick={() => updateFilter('flipH', !imageFilters.flipH)}
colorScheme="blue"
variant={imageFilters.flipH ? 'solid' : 'outline'}
/>
</Tooltip>
<Tooltip label="Převrátit vertikálně">
<IconButton
aria-label="Flip vertical"
icon={<FlipVertical size={16} />}
size="sm"
onClick={() => updateFilter('flipV', !imageFilters.flipV)}
colorScheme="blue"
variant={imageFilters.flipV ? 'solid' : 'outline'}
/>
</Tooltip>
<Tooltip label="Resetovat vše">
<IconButton
aria-label="Reset filters"
icon={<RotateCcw size={16} />}
size="sm"
onClick={resetFilters}
colorScheme="red"
variant="outline"
/>
</Tooltip>
</HStack>
{/* Filter Sliders */}
<VStack align="stretch" spacing={2}>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Sun size={14} />
<FormLabel fontSize="xs" mb={0}>Jas</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.brightness}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.brightness}
onChange={(e) => updateFilter('brightness', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Contrast size={14} />
<FormLabel fontSize="xs" mb={0}>Kontrast</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.contrast}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.contrast}
onChange={(e) => updateFilter('contrast', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Droplets size={14} />
<FormLabel fontSize="xs" mb={0}>Sytost</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.saturation}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.saturation}
onChange={(e) => updateFilter('saturation', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Eye size={14} />
<FormLabel fontSize="xs" mb={0}>Rozostření</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.blur}px</Text>
</HStack>
<input
type="range"
min="0"
max="10"
step="0.5"
value={imageFilters.blur}
onChange={(e) => updateFilter('blur', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
</VStack>
{/* Quick Filters */}
<HStack spacing={2} flexWrap="wrap">
<Button
size="xs"
onClick={() => {
updateFilter('grayscale', imageFilters.grayscale === 100 ? 0 : 100);
}}
colorScheme={imageFilters.grayscale === 100 ? 'purple' : 'gray'}
variant={imageFilters.grayscale === 100 ? 'solid' : 'outline'}
leftIcon={<Filter size={12} />}
>
Černobílá
</Button>
<Button
size="xs"
onClick={() => {
updateFilter('sepia', imageFilters.sepia === 100 ? 0 : 100);
}}
colorScheme={imageFilters.sepia === 100 ? 'orange' : 'gray'}
variant={imageFilters.sepia === 100 ? 'solid' : 'outline'}
leftIcon={<Sparkles size={12} />}
>
Sepia
</Button>
</HStack>
</VStack>
</Box>
)}
{/* Crop Modal */}
<Modal isOpen={cropOpen} onClose={() => { setCropOpen(false); setCropSrc(null); }} size="6xl">
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(4px)" />
<ModalContent maxW="90vw" maxH="90vh">
<ModalHeader>Oříznout a upravit obrázek</ModalHeader>
<ModalCloseButton />
<ModalBody maxH="calc(90vh - 140px)" overflowY="auto" overflowX="hidden">
<VStack align="stretch" spacing={4}>
{cropSrc && (
<Box
display="flex"
justifyContent="center"
alignItems="center"
p={4}
bg={useColorModeValue('gray.50', 'gray.900')}
borderRadius="md"
>
<ReactCrop
crop={crop}
onChange={(c: Crop) => setCrop(c)}
minWidth={50}
minHeight={50}
keepSelection
>
<img
ref={imgRef as any}
src={cropSrc}
alt="Crop preview"
style={{
maxWidth: '100%',
maxHeight: '60vh',
display: 'block',
margin: 'auto'
}}
/>
</ReactCrop>
</Box>
)}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl>
<FormLabel fontSize="sm">Max. šířka (px)</FormLabel>
<HStack>
<Input
type="number"
value={cropMaxWidth}
onChange={(e) => setCropMaxWidth(Math.max(100, Math.min(3000, Number(e.target.value))))}
min={100}
max={3000}
step={100}
size="sm"
/>
<Text fontSize="sm" color="gray.600" whiteSpace="nowrap">px</Text>
</HStack>
<FormHelperText fontSize="xs">
Větší obrázky budou zmenšeny (optimalizace výkonu)
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Kvalita JPEG</FormLabel>
<HStack>
<Input
type="number"
value={cropQuality}
onChange={(e) => setCropQuality(Math.max(1, Math.min(100, Number(e.target.value))))}
min={1}
max={100}
step={5}
size="sm"
/>
<Text fontSize="sm" color="gray.600" whiteSpace="nowrap">%</Text>
</HStack>
<FormHelperText fontSize="xs">
85% je doporučená hodnota (menší velikost souboru)
</FormHelperText>
</FormControl>
</SimpleGrid>
<Text fontSize="sm" color="gray.600">
💡 Přetáhněte rohy a hrany modré oblasti pro výběr části obrázku k oříznutí.
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => { setCropOpen(false); setCropSrc(null); }}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={confirmCropAndInsert}>
Oříznout a vložit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
);
};
export default CustomRichEditor;