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, 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 = ({ value, onChange, placeholder = 'Začněte psát...', height = '400px', readOnly = false, onImageUpload, showImageResize = true, toolbar = 'full', }) => { const toast = useToast(); const quillRef = useRef(null); const [editorMode, setEditorMode] = useState<'rich' | 'html'>('rich'); // Crop modal state const [cropOpen, setCropOpen] = useState(false); const [cropSrc, setCropSrc] = useState(null); const [crop, setCrop] = useState({ unit: '%', width: 80, height: 80, x: 10, y: 10 }); const [cropQuality, setCropQuality] = useState(85); const [cropMaxWidth, setCropMaxWidth] = useState(1500); const imgRef = useRef(null); // Force white mode for better readability in admin const borderColor = 'gray.200'; const bgColor = 'white'; const hoverBg = 'gray.50'; const toolbarBg = 'white'; const toolbarBorder = 'gray.200'; // Image editing state const [selectedImageElement, setSelectedImageElement] = useState(null); const [imageFilters, setImageFilters] = useState({ 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 => { 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 ( {/* Editor Controls */} {!readOnly && ( {editorMode === 'rich' && onImageUpload && ( )} )} {editorMode === 'rich' ? ( ) : ( 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' && ( 💡 Tip: Klikněte na obrázek pro výběr a úpravu. Používejte nástrojovou lištu pro filtry a transformace. )} {/* Floating Image Editing Toolbar */} {showImageToolbar && selectedImageElement && !readOnly && ( {/* Toolbar Header */} Úprava obrázku } size="xs" onClick={() => setShowImageToolbar(false)} variant="ghost" /> {/* Transform Buttons */} } size="sm" onClick={() => updateFilter('rotation', (imageFilters.rotation - 90) % 360)} colorScheme="blue" variant="outline" /> } size="sm" onClick={() => updateFilter('rotation', (imageFilters.rotation + 90) % 360)} colorScheme="blue" variant="outline" /> } size="sm" onClick={() => updateFilter('flipH', !imageFilters.flipH)} colorScheme="blue" variant={imageFilters.flipH ? 'solid' : 'outline'} /> } size="sm" onClick={() => updateFilter('flipV', !imageFilters.flipV)} colorScheme="blue" variant={imageFilters.flipV ? 'solid' : 'outline'} /> } size="sm" onClick={resetFilters} colorScheme="red" variant="outline" /> {/* Filter Sliders */} Jas {imageFilters.brightness}% updateFilter('brightness', Number(e.target.value))} style={{ width: '100%' }} /> Kontrast {imageFilters.contrast}% updateFilter('contrast', Number(e.target.value))} style={{ width: '100%' }} /> Sytost {imageFilters.saturation}% updateFilter('saturation', Number(e.target.value))} style={{ width: '100%' }} /> Rozostření {imageFilters.blur}px updateFilter('blur', Number(e.target.value))} style={{ width: '100%' }} /> {/* Quick Filters */} )} {/* Crop Modal */} { setCropOpen(false); setCropSrc(null); }} size="6xl"> Oříznout a upravit obrázek {cropSrc && ( setCrop(c)} minWidth={50} minHeight={50} keepSelection > Crop preview )} Max. šířka (px) setCropMaxWidth(Math.max(100, Math.min(3000, Number(e.target.value))))} min={100} max={3000} step={100} size="sm" /> px Větší obrázky budou zmenšeny (optimalizace výkonu) Kvalita JPEG setCropQuality(Math.max(1, Math.min(100, Number(e.target.value))))} min={1} max={100} step={5} size="sm" /> % 85% je doporučená hodnota (menší velikost souboru) 💡 Přetáhněte rohy a hrany modré oblasti pro výběr části obrázku k oříznutí. ); }; export default CustomRichEditor;