import React, { useRef, useCallback, useState, useEffect, useMemo } 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'; import { Image as ChakraImage } from '@chakra-ui/react'; import { cropAndUpload, quickEditImage } from '../../services/imageProcessing'; import { assetUrl } from '../../utils/url'; 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 toolbarRef = useRef(null); const onChangeRef = useRef(onChange); const selectedImageIdRef = useRef(null); const selectImageByIdRef = useRef<(id: string) => void>(() => {}); const toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 }); const [isMounted, setIsMounted] = useState(false); // Ensure component is mounted before rendering Quill useEffect(() => { setIsMounted(true); return () => setIsMounted(false); }, []); // Keep onChange ref up to date useEffect(() => { onChangeRef.current = onChange; }, [onChange]); // Crop modal state const [cropOpen, setCropOpen] = useState(false); const [cropSrc, setCropSrc] = useState(null); const [cropFile, setCropFile] = 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 [cropProcessing, setCropProcessing] = useState(false); 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 }); const [previewImage, setPreviewImage] = useState(null); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [imageWidth, setImageWidth] = useState(0); const [manualWidth, setManualWidth] = useState(''); const [widthPercent, setWidthPercent] = useState(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'], ['blockquote'], ['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 toolbarConfig = useMemo(() => { return toolbarConfigs[toolbar] || toolbarConfigs.full; }, [toolbar]); // 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; setCropFile(file); const reader = new FileReader(); reader.onload = () => { setCropSrc(reader.result as string); setCropOpen(true); }; reader.readAsDataURL(file); }; input.click(); }, []); // Memoize modules to prevent Quill reinitialization const quillModules = useMemo(() => ({ toolbar: { container: toolbarConfig, handlers: { image: onImageUpload ? handleImageUpload : undefined, }, }, clipboard: { matchVisual: false, }, }), [toolbarConfig, onImageUpload, handleImageUpload]); // Localize Quill toolbar tooltips/labels to Czech useEffect(() => { if (!isMounted) return; const editor = quillRef.current?.getEditor(); if (!editor) return; const container = editor.root?.parentElement; // .ql-container const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar if (!toolbarEl) return; const setTitle = (selector: string, title: string) => { toolbarEl.querySelectorAll(selector).forEach((el) => { (el as HTMLElement).setAttribute('title', title); (el as HTMLElement).setAttribute('aria-label', title); }); }; // Basic formatting setTitle('button.ql-bold', 'Tučné'); setTitle('button.ql-italic', 'Kurzíva'); setTitle('button.ql-underline', 'Podtržení'); setTitle('button.ql-strike', 'Přeškrtnutí'); setTitle('button.ql-link', 'Vložit odkaz'); setTitle('button.ql-image', 'Vložit obrázek'); setTitle('button.ql-blockquote', 'Citace'); setTitle('button.ql-clean', 'Vyčistit formátování'); // Lists setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam'); setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam'); // Alignment setTitle('button.ql-align', 'Zarovnání'); setTitle('button.ql-align[value=""]', 'Zarovnat vlevo'); setTitle('button.ql-align[value="center"]', 'Zarovnat na střed'); setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo'); setTitle('button.ql-align[value="justify"]', 'Do bloku'); // Colors and background setTitle('.ql-color .ql-picker-label', 'Barva textu'); setTitle('.ql-background .ql-picker-label', 'Barva pozadí'); // Headers setTitle('.ql-header .ql-picker-label', 'Nadpis'); setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1'); setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2'); setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3'); }, [isMounted, toolbar]); // 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 { setCropProcessing(true); if (!cropFile) { toast({ title: 'Chyba', description: 'Soubor není načten', status: 'error' }); return; } if (!imgRef.current) { toast({ title: 'Chyba', description: 'Obrázek není načten', status: 'error' }); return; } // Calculate crop data in natural image pixels (backend expects absolute pixels) const img = imgRef.current; const displayW = img.width; const displayH = img.height; const naturalW = img.naturalWidth || displayW; const naturalH = img.naturalHeight || displayH; const scaleX = naturalW / Math.max(1, displayW); const scaleY = naturalH / Math.max(1, displayH); const toDisplayPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val); let cropData = undefined; if (crop.width && crop.height && crop.width > 0 && crop.height > 0) { // Convert selection from displayed coordinates to natural pixel coordinates const dispX = Math.max(0, toDisplayPx(crop.x || 0, displayW)); const dispY = Math.max(0, toDisplayPx(crop.y || 0, displayH)); const dispW = Math.min(displayW, toDisplayPx(crop.width || displayW, displayW)); const dispH = Math.min(displayH, toDisplayPx(crop.height || displayH, displayH)); let natX = Math.round(dispX * scaleX); let natY = Math.round(dispY * scaleY); let natW = Math.round(dispW * scaleX); let natH = Math.round(dispH * scaleY); // Clamp within natural bounds if (natX + natW > naturalW) natW = naturalW - natX; if (natY + natH > naturalH) natH = naturalH - natY; natW = Math.max(1, natW); natH = Math.max(1, natH); cropData = { x: natX, y: natY, width: natW, height: natH }; } toast({ title: 'Zpracování obrázku...', status: 'info', duration: 2000 }); // Use backend to crop and upload const res = await cropAndUpload(cropFile, cropData, cropQuality, cropMaxWidth); if (!res.url) { throw new Error('Upload failed - no URL returned'); } // Insert into editor const quill = quillRef.current?.getEditor(); if (quill) { // Ensure editor is focused and ready quill.focus(); // Use setTimeout to ensure Quill's internal state is ready setTimeout(() => { try { const range = quill.getSelection(); const index = range ? range.index : quill.getLength(); const absoluteUrl = assetUrl(res.url) || res.url; const img = new Image(); img.onload = () => { try { quill.insertEmbed(index, 'image', absoluteUrl, 'api'); // Move cursor after the image quill.setSelection(index + 1, 0, 'api'); // Force content change to trigger re-render onChangeRef.current(quill.root.innerHTML); toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 }); } catch (e) { console.error('Insert after preload error:', e); toast({ title: 'Chyba při vkládání obrázku', description: String(e), status: 'error' }); } }; img.onerror = () => { toast({ title: 'Obrázek nelze načíst', description: absoluteUrl, status: 'error' }); }; img.src = absoluteUrl; } catch (embedError) { console.error('Error inserting image:', embedError); toast({ title: 'Chyba při vkládání obrázku', description: String(embedError), status: 'error' }); } }, 50); } } catch (e: any) { console.error('Crop and insert error:', e); toast({ title: 'Zpracování obrázku selhalo', description: e?.response?.data?.error || e?.message || 'Chyba', status: 'error' }); } finally { setCropProcessing(false); setCropOpen(false); setCropSrc(null); setCropFile(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; let rafId = 0; const createResizeHandle = (img: HTMLImageElement) => { removeResizeHandle(); // Create container for all resize handles const container = document.createElement('div'); container.className = 'custom-image-resize-container'; container.style.cssText = ` position: absolute; pointer-events: none; z-index: 1000; `; const rect = img.getBoundingClientRect(); const editorRect = editor.root.getBoundingClientRect(); const scrollTop = editor.root.scrollTop; const scrollLeft = editor.root.scrollLeft; const sizeLabel = document.createElement('div'); sizeLabel.style.cssText = ` position: absolute; top: -26px; right: 0; background: rgba(26,32,44,0.9); color: #fff; font-size: 11px; line-height: 1; padding: 4px 6px; border-radius: 4px; pointer-events: none; box-shadow: 0 2px 6px rgba(0,0,0,0.3); `; const updateSizeLabel = (w: number) => { try { const edW = editor.root.clientWidth || w || 1; const pct = Math.max(1, Math.min(100, Math.round((w / edW) * 100))); sizeLabel.textContent = `${Math.round(w)} px (${pct}%)`; } catch { sizeLabel.textContent = `${Math.round(w)} px`; } }; // Create edge handles (right, bottom, left, top) const handles = [ { position: 'right', cursor: 'ew-resize', width: '12px', height: '60%' }, { position: 'bottom', cursor: 'ns-resize', width: '60%', height: '12px' }, { position: 'left', cursor: 'ew-resize', width: '12px', height: '60%' }, { position: 'top', cursor: 'ns-resize', width: '60%', height: '12px' }, { position: 'bottom-right', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true }, { position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true }, { position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true }, { position: 'top-left', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true }, ]; const updateHandlePositions = () => { const rect = img.getBoundingClientRect(); const editorRect = editor.root.getBoundingClientRect(); const scrollTop = editor.root.scrollTop; const scrollLeft = editor.root.scrollLeft; container.style.left = `${rect.left - editorRect.left + scrollLeft}px`; container.style.top = `${rect.top - editorRect.top + scrollTop}px`; container.style.width = `${rect.width}px`; container.style.height = `${rect.height}px`; }; handles.forEach(({ position, cursor, width, height, isCorner }) => { const handle = document.createElement('div'); handle.className = `custom-image-resize-handle custom-resize-${position}`; let positionStyle = 'position: absolute; pointer-events: auto; transition: all 0.2s;'; if (isCorner) { // Corner handle - circular blue dot positionStyle += ` width: ${width}; height: ${height}; background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%); border: 3px solid white; border-radius: 50%; box-shadow: 0 2px 8px rgba(0,0,0,0.4), 0 0 0 1px rgba(49, 130, 206, 0.3); `; if (position === 'bottom-right') { positionStyle += 'right: -8px; bottom: -8px;'; } else if (position === 'bottom-left') { positionStyle += 'left: -8px; bottom: -8px;'; } else if (position === 'top-right') { positionStyle += 'right: -8px; top: -8px;'; } else if (position === 'top-left') { positionStyle += 'left: -8px; top: -8px;'; } } else { // Edge handle - thin blue bar positionStyle += ` background: rgba(49, 130, 206, 0.6); border: 1px solid rgba(49, 130, 206, 0.8); `; if (position === 'right') { positionStyle += `width: ${width}; height: ${height}; right: -4px; top: 50%; transform: translateY(-50%);`; } else if (position === 'bottom') { positionStyle += `width: ${width}; height: ${height}; bottom: -4px; left: 50%; transform: translateX(-50%);`; } else if (position === 'left') { positionStyle += `width: ${width}; height: ${height}; left: -4px; top: 50%; transform: translateY(-50%);`; } else if (position === 'top') { positionStyle += `width: ${width}; height: ${height}; top: -4px; left: 50%; transform: translateX(-50%);`; } } positionStyle += `cursor: ${cursor};`; handle.style.cssText = positionStyle; // Hover effect for corners if (isCorner) { handle.addEventListener('mouseenter', () => { handle.style.transform = 'scale(1.3)'; handle.style.boxShadow = '0 3px 12px rgba(0,0,0,0.5), 0 0 0 2px rgba(49, 130, 206, 0.5)'; }); handle.addEventListener('mouseleave', () => { if (!isResizing) { handle.style.transform = 'scale(1)'; handle.style.boxShadow = '0 2px 8px rgba(0,0,0,0.4), 0 0 0 1px rgba(49, 130, 206, 0.3)'; } }); } else { // Hover effect for edges handle.addEventListener('mouseenter', () => { handle.style.background = 'rgba(49, 130, 206, 0.9)'; }); handle.addEventListener('mouseleave', () => { if (!isResizing) { handle.style.background = 'rgba(49, 130, 206, 0.6)'; } }); } handle.addEventListener('pointerdown', (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); isResizing = true; startX = e.clientX; startY = e.clientY; startWidth = img.offsetWidth; const startHeight = img.offsetHeight; const aspectRatio = startWidth / startHeight; const onPointerMove = (ev: PointerEvent) => { if (!isResizing) return; const deltaX = ev.clientX - startX; const deltaY = ev.clientY - startY; let newWidth = startWidth; if (position.includes('right')) { newWidth = startWidth + deltaX; } else if (position.includes('left')) { newWidth = startWidth - deltaX; } else if (position.includes('bottom') || position.includes('top')) { newWidth = startWidth + (deltaY * aspectRatio); } newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40)); img.style.width = `${newWidth}px`; img.style.maxWidth = '100%'; img.style.height = 'auto'; try { img.setAttribute('width', String(Math.round(newWidth))); } catch {} setImageWidth(newWidth); setManualWidth(newWidth.toString()); try { const editorWidth = editor.root.clientWidth || newWidth || 1; setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100)))); } catch {} updateHandlePositions(); updateSizeLabel(newWidth); }; const onPointerUp = () => { isResizing = false; document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); onChangeRef.current(editor.root.innerHTML); const id = selectedImageIdRef.current; setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30); }; document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); }); container.appendChild(handle); }); updateHandlePositions(); updateSizeLabel(img.offsetWidth || img.width || 0); editor.root.style.position = 'relative'; editor.root.appendChild(container); container.appendChild(sizeLabel); resizeHandle = container; return container; }; 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; // Ensure image has a persistent ID for reselection after content updates let id = img.getAttribute('data-img-id') || ''; if (!id) { id = 'img-' + Date.now() + '-' + Math.random().toString(36).slice(2); try { img.setAttribute('data-img-id', id); } catch {} } selectedImageIdRef.current = id; img.style.outline = '3px solid #3182ce'; img.style.cursor = 'move'; img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)'; // Prevent default drag behavior to avoid duplication img.setAttribute('draggable', 'false'); createResizeHandle(img); // Set selected image state and load filters setSelectedImageElement(img); // Get current width const currentWidth = img.offsetWidth || img.width; setImageWidth(currentWidth); setManualWidth(currentWidth.toString()); try { const editorWidth = editor.root.clientWidth || currentWidth || 1; setWidthPercent(Math.max(1, Math.min(100, Math.round((currentWidth / editorWidth) * 100)))); } catch {} // Load saved filters const filtersData = img.getAttribute('data-filters'); if (filtersData) { try { const savedFilters = JSON.parse(filtersData); setImageFilters(savedFilters); } catch { // If parsing fails, reset to defaults setImageFilters({ brightness: 100, contrast: 100, saturation: 100, blur: 0, grayscale: 0, sepia: 0, hueRotate: 0, rotation: 0, flipH: false, flipV: false, }); } } // Show toolbar and position it next to the image const rect = img.getBoundingClientRect(); const editorRect = editor.root.getBoundingClientRect(); const scrollTop = editor.root.scrollTop; const scrollLeft = editor.root.scrollLeft; // Place toolbar close to the image (top-right corner inside the image area when possible) const toolbarWidth = 380; const margin = 8; let leftPos = rect.left - editorRect.left + scrollLeft + (rect.width > toolbarWidth + margin ? (rect.width - toolbarWidth - margin) : margin); leftPos = Math.max(margin, Math.min(leftPos, editorRect.width - toolbarWidth - margin)); let topPos = rect.top - editorRect.top + scrollTop + margin; topPos = Math.max(margin, topPos); setToolbarPosition({ top: topPos, left: leftPos, }); setShowImageToolbar(true); }; // Expose reselection helper bound to current effect scope selectImageByIdRef.current = (id: string) => { const ed = quillRef.current?.getEditor(); if (!ed) return; const node = ed.root.querySelector(`img[data-img-id="${id}"]`) as HTMLImageElement | null; if (node) { selectImage(node); } }; const deselectImage = () => { if (selectedImage) { selectedImage.style.outline = ''; selectedImage.style.cursor = ''; selectedImage.style.boxShadow = ''; selectedImage = null; } removeResizeHandle(); setSelectedImageElement(null); setShowImageToolbar(false); setImageWidth(0); setManualWidth(''); }; const handleImageClick = (e: Event) => { const target = e.target as HTMLElement; if (target.tagName === 'IMG') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); // In read-only mode, show preview instead of selecting if (readOnly) { const imgSrc = (target as HTMLImageElement).src; setPreviewImage(imgSrc); setIsPreviewOpen(true); return; } selectImage(target as HTMLImageElement); return; // Important: return early to prevent further processing } // Don't deselect if clicking on resize handle or toolbar if (target.classList.contains('custom-image-resize-handle') || target.classList.contains('custom-image-resize-container')) { e.preventDefault(); e.stopPropagation(); return; } if (toolbarRef.current && (toolbarRef.current === target || toolbarRef.current.contains(target))) { e.preventDefault(); e.stopPropagation(); return; // Don't deselect if clicking inside toolbar } // Only deselect if clicking outside image, toolbar, and handle deselectImage(); }; const handleMouseDown = (e: MouseEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'IMG' && selectedImage === target) { // Allow edge-drag fallback resize if overlay handle doesn't catch it const rect = target.getBoundingClientRect(); const nearLeft = e.clientX < rect.left + 16; const nearRight = e.clientX > rect.right - 16; const nearTop = e.clientY < rect.top + 16; const nearBottom = e.clientY > rect.bottom - 16; if (nearLeft || nearRight || nearTop || nearBottom) { e.preventDefault(); e.stopPropagation(); isResizing = true; startX = e.clientX; startY = e.clientY; startWidth = (target as HTMLImageElement).offsetWidth; const startHeight = (target as HTMLImageElement).offsetHeight; const aspectRatio = startWidth / Math.max(1, startHeight); const edge = nearRight ? 'right' : nearLeft ? 'left' : nearBottom ? 'bottom' : 'top'; const onMouseMove: (ev: MouseEvent) => void = (ev: MouseEvent) => { if (!isResizing) return; const deltaX = ev.clientX - startX; const deltaY = ev.clientY - startY; let newWidth = startWidth; if (edge === 'right') newWidth = startWidth + deltaX; else if (edge === 'left') newWidth = startWidth - deltaX; else if (edge === 'bottom') newWidth = startWidth + (deltaY * aspectRatio); else if (edge === 'top') newWidth = startWidth - (deltaY * aspectRatio); const maxW = editor.root.clientWidth - 40; newWidth = Math.max(50, Math.min(newWidth, maxW)); const imgEl = target as HTMLImageElement; imgEl.style.width = `${newWidth}px`; imgEl.style.maxWidth = '100%'; imgEl.style.height = 'auto'; try { imgEl.setAttribute('width', String(Math.round(newWidth))); } catch {} setImageWidth(newWidth); setManualWidth(String(Math.round(newWidth))); try { const editorWidth = editor.root.clientWidth || newWidth || 1; setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100)))); } catch {} handleScroll(); }; const onMouseUp = () => { isResizing = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); onChangeRef.current(editor.root.innerHTML); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); return; } e.preventDefault(); e.stopPropagation(); isDragging = true; startX = e.clientX; startY = e.clientY; // Store initial alignment to prevent copying behavior const initialMarginLeft = selectedImage.style.marginLeft || ''; const initialMarginRight = selectedImage.style.marginRight || ''; let currentAlignment: 'left' | 'center' | 'right' = 'center'; if (initialMarginLeft === '0' || initialMarginLeft === '0px') { currentAlignment = 'left'; } else if (initialMarginRight === '0' || initialMarginRight === '0px') { currentAlignment = 'right'; } // Already set in selectImage, but ensure it's off target.setAttribute('draggable', 'false'); const onMouseMove: (e: MouseEvent) => void = (e: MouseEvent) => { if (!isDragging || !selectedImage) return; const deltaX = e.clientX - startX; // Require significant movement to trigger alignment change if (Math.abs(deltaX) > 50) { const newAlignment = deltaX > 0 ? 'right' : 'left'; // Only update if alignment actually changed if (newAlignment !== currentAlignment) { currentAlignment = newAlignment; startX = e.clientX; // Reset start position selectedImage.style.display = 'block'; selectedImage.style.float = 'none'; if (newAlignment === 'right') { selectedImage.style.marginLeft = 'auto'; selectedImage.style.marginRight = '0'; } else { selectedImage.style.marginLeft = '0'; selectedImage.style.marginRight = 'auto'; } } } }; const onMouseUp: (e: MouseEvent) => void = () => { isDragging = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); if (selectedImage) { onChangeRef.current(editor.root.innerHTML); } }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); } }; // Delete selected image on Delete key const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; const tag = target?.tagName; // Do not act on Delete/Backspace if user is typing in an input, textarea, or contentEditable if (tag === 'INPUT' || tag === 'TEXTAREA' || (target && (target as any).isContentEditable)) { return; } if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) { e.preventDefault(); selectedImage.remove(); deselectImage(); onChangeRef.current(editor.root.innerHTML); toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 }); } }; // Handle scroll to update resize handle position const handleScroll = () => { if (!selectedImage || !resizeHandle) return; if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { const rect = selectedImage!.getBoundingClientRect(); const editorRect = editor.root.getBoundingClientRect(); const scrollTop = editor.root.scrollTop; const scrollLeft = editor.root.scrollLeft; resizeHandle!.style.left = `${rect.left - editorRect.left + scrollLeft}px`; resizeHandle!.style.top = `${rect.top - editorRect.top + scrollTop}px`; resizeHandle!.style.width = `${rect.width}px`; resizeHandle!.style.height = `${rect.height}px`; }); }; // Prevent default drag behavior on images const handleDragStart = (e: DragEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'IMG') { e.preventDefault(); e.stopPropagation(); return false; } }; editor.root.addEventListener('click', handleImageClick); editor.root.addEventListener('mousedown', handleMouseDown); editor.root.addEventListener('scroll', handleScroll); editor.root.addEventListener('dragstart', handleDragStart); document.addEventListener('keydown', handleKeyDown); // Also reposition on window resize and any document scroll (capture phase) window.addEventListener('resize', handleScroll); document.addEventListener('scroll', handleScroll, true); return () => { editor.root.removeEventListener('click', handleImageClick); editor.root.removeEventListener('mousedown', handleMouseDown); editor.root.removeEventListener('scroll', handleScroll); editor.root.removeEventListener('dragstart', handleDragStart); document.removeEventListener('keydown', handleKeyDown); window.removeEventListener('resize', handleScroll); document.removeEventListener('scroll', handleScroll, true); if (rafId) cancelAnimationFrame(rafId); removeResizeHandle(); deselectImage(); }; }, [readOnly, toast, isMounted]); // 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 (preview only - client-side) const updateFilter = useCallback((key: keyof ImageFilters, value: any) => { setImageFilters(prev => { const newFilters = { ...prev, [key]: value }; if (selectedImageElement) { applyFiltersToImage(selectedImageElement, newFilters); } return newFilters; }); }, [selectedImageElement, applyFiltersToImage]); // Apply filters via backend and replace image const [isApplyingFilters, setIsApplyingFilters] = useState(false); const applyFiltersToBackend = useCallback(async () => { if (!selectedImageElement) return; try { setIsApplyingFilters(true); toast({ title: 'Zpracování filtry...', status: 'info', duration: 2000 }); const currentSrc = selectedImageElement.src; // Prepare filter values for backend (convert from % to -100 to 100 scale) const brightness = imageFilters.brightness - 100; const contrast = imageFilters.contrast - 100; const saturation = imageFilters.saturation - 100; const res = await quickEditImage({ image_url: currentSrc, width: selectedImageElement.offsetWidth || undefined, rotation: imageFilters.rotation, flip_h: imageFilters.flipH, flip_v: imageFilters.flipV, brightness, contrast, saturation, grayscale: imageFilters.grayscale > 0, quality: 85, }); // Replace image src const absoluteUrl = assetUrl(res.url) || res.url; selectedImageElement.src = absoluteUrl; // Reset filters to default since they're now baked into the image setImageFilters({ brightness: 100, contrast: 100, saturation: 100, blur: 0, grayscale: 0, sepia: 0, hueRotate: 0, rotation: 0, flipH: false, flipV: false, }); selectedImageElement.style.filter = ''; selectedImageElement.style.transform = ''; selectedImageElement.removeAttribute('data-filters'); // Update editor content const editor = quillRef.current?.getEditor(); if (editor) { onChangeRef.current(editor.root.innerHTML); } reselectAfterContentUpdate(); toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 }); } catch (e: any) { console.error('Apply filters error:', e); toast({ title: 'Aplikace filtrů selhala', description: e?.response?.data?.error || e?.message, status: 'error' }); } finally { setIsApplyingFilters(false); } }, [selectedImageElement, imageFilters, toast]); // Align image const alignImage = useCallback((alignment: 'left' | 'center' | 'right') => { if (selectedImageElement) { selectedImageElement.style.display = 'block'; selectedImageElement.style.float = 'none'; if (alignment === 'left') { selectedImageElement.style.marginLeft = '0'; selectedImageElement.style.marginRight = 'auto'; } else if (alignment === 'center') { selectedImageElement.style.marginLeft = 'auto'; selectedImageElement.style.marginRight = 'auto'; } else if (alignment === 'right') { selectedImageElement.style.marginLeft = 'auto'; selectedImageElement.style.marginRight = '0'; } const editor = quillRef.current?.getEditor(); if (editor) { onChangeRef.current(editor.root.innerHTML); // Force overlay reposition try { editor.root.dispatchEvent(new Event('scroll')); } catch {} } reselectAfterContentUpdate(); toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 }); } }, [selectedImageElement, toast]); // Reselect helper after content updates (e.g., when value change triggers rerender) const reselectAfterContentUpdate = useCallback(() => { const id = selectedImageIdRef.current; if (!id) return; setTimeout(() => { try { selectImageByIdRef.current?.(id); } catch {} }, 30); }, []); const applyWidthPx = useCallback((px: number, opts?: { silent?: boolean }) => { if (!selectedImageElement) return; const editor = quillRef.current?.getEditor(); const maxWidth = editor ? editor.root.clientWidth - 40 : 1200; const finalWidth = Math.min(Math.max(50, Math.round(px)), maxWidth); selectedImageElement.style.width = `${finalWidth}px`; selectedImageElement.style.height = 'auto'; selectedImageElement.style.maxWidth = '100%'; selectedImageElement.setAttribute('width', String(finalWidth)); setImageWidth(finalWidth); setManualWidth(finalWidth.toString()); if (editor) { onChangeRef.current(editor.root.innerHTML); } // Keep selection active for subsequent operations (e.g., 50% → 75%) reselectAfterContentUpdate(); if (!opts?.silent) { toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 }); } }, [selectedImageElement, toast]); const resetWidth = useCallback(() => { if (!selectedImageElement) return; const editor = quillRef.current?.getEditor(); selectedImageElement.style.width = ''; selectedImageElement.style.height = ''; selectedImageElement.style.maxWidth = '100%'; selectedImageElement.removeAttribute('width'); const currentWidth = selectedImageElement.offsetWidth || selectedImageElement.width || 0; setImageWidth(currentWidth); setManualWidth(''); if (editor) { onChangeRef.current(editor.root.innerHTML); } reselectAfterContentUpdate(); toast({ title: 'Šířka resetována', status: 'info', duration: 1200 }); }, [selectedImageElement, toast]); const applyPercent = useCallback((percent: number, opts?: { silent?: boolean }) => { const clamped = Math.max(5, Math.min(100, Math.round(percent))); setWidthPercent(clamped); const editor = quillRef.current?.getEditor(); if (editor && selectedImageElement) { const px = (editor.root.clientWidth * clamped) / 100; applyWidthPx(px, opts); } }, [applyWidthPx, selectedImageElement]); // Set manual width const applyManualWidth = useCallback(() => { if (selectedImageElement && manualWidth) { const raw = manualWidth.trim(); if (raw.endsWith('%')) { const percent = parseFloat(raw.slice(0, -1)); const editor = quillRef.current?.getEditor(); if (editor && !isNaN(percent) && percent > 0) { const px = (editor.root.clientWidth * percent) / 100; applyWidthPx(px); return; } } const width = parseInt(raw, 10); if (!isNaN(width) && width > 0) { applyWidthPx(width); } else { toast({ title: 'Neplatná šířka', description: 'Zadejte kladné číslo nebo procenta (např. 50%)', status: 'warning', duration: 1500 }); } } }, [selectedImageElement, manualWidth, toast, applyWidthPx]); // Delete selected image const deleteSelectedImage = useCallback(() => { if (selectedImageElement) { selectedImageElement.remove(); setSelectedImageElement(null); setShowImageToolbar(false); const editor = quillRef.current?.getEditor(); if (editor) { onChangeRef.current(editor.root.innerHTML); } toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 }); } }, [selectedImageElement, toast]); // Sanitize HTML on change and fix white text colors const handleChange = (content: string) => { // First sanitize let cleaned = DOMPurify.sanitize(content, { USE_PROFILES: { html: true }, ADD_TAGS: ['iframe'], ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'], }); // Replace white and very light colors with dark colors for visibility const whiteColorPatterns = [ /color:\s*rgb\(255,\s*255,\s*255\)/gi, /color:\s*rgb\(255\s*,\s*255\s*,\s*255\)/gi, /color:\s*white/gi, /color:\s*#fff(?:fff)?(?=[;\s"'])/gi, /color:\s*rgba?\(255,\s*255,\s*255/gi, // Very light grays that are barely visible on white /color:\s*rgb\(25[0-4],\s*25[0-4],\s*25[0-4]\)/gi, /color:\s*rgb\(24[5-9],\s*24[5-9],\s*24[5-9]\)/gi, ]; whiteColorPatterns.forEach(pattern => { cleaned = cleaned.replace(pattern, 'color: #1a202c'); }); onChangeRef.current(cleaned); }; return ( {/* Editor Controls */} {!readOnly && onImageUpload && ( nebo použijte tlačítko obrázku v nástrojové liště )} {isMounted && ( )} {!readOnly && ( 💡 Tip: Klikněte na obrázek pro výběr. Poté můžete: • Přetáhnout pro zarovnání • Změnit velikost tažením modrého bodu • Upravit filtry a transformace • Stisknout Delete pro smazání )} {/* Floating Image Editing Toolbar */} {showImageToolbar && selectedImageElement && !readOnly && ( { e.stopPropagation(); }} onMouseDown={(e) => { e.stopPropagation(); }} onMouseUp={(e) => { e.stopPropagation(); }} css={{ '&::-webkit-scrollbar': { width: '6px', }, '&::-webkit-scrollbar-track': { background: '#f1f1f1', }, '&::-webkit-scrollbar-thumb': { background: '#888', borderRadius: '3px', }, '&::-webkit-scrollbar-thumb:hover': { background: '#555', }, }} > {/* Toolbar Header */} { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); toolbarDragRef.current.active = true; toolbarDragRef.current.startX = e.clientX; toolbarDragRef.current.startY = e.clientY; toolbarDragRef.current.startLeft = toolbarPosition.left; toolbarDragRef.current.startTop = toolbarPosition.top; const onMove = (ev: MouseEvent) => { if (!toolbarDragRef.current.active) return; const dx = ev.clientX - toolbarDragRef.current.startX; const dy = ev.clientY - toolbarDragRef.current.startY; setToolbarPosition((pos) => ({ top: Math.max(0, toolbarDragRef.current.startTop + dy), left: Math.max(0, toolbarDragRef.current.startLeft + dx) })); }; const onUp = () => { toolbarDragRef.current.active = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} cursor="move" > Úprava obrázku } size="xs" onClick={() => setShowImageToolbar(false)} variant="ghost" /> {/* Alignment Buttons */} Zarovnání } size="sm" onClick={() => alignImage('left')} colorScheme="teal" variant="outline" flex={1} /> } size="sm" onClick={() => alignImage('center')} colorScheme="teal" variant="outline" flex={1} /> } size="sm" onClick={() => alignImage('right')} colorScheme="teal" variant="outline" flex={1} /> {/* Width Control */ } Šířka obrázku setManualWidth(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); applyManualWidth(); } }} placeholder="Šířka v px" min={50} /> Aktuální: {imageWidth}px ({widthPercent || 0}%) Šířka (%) {widthPercent || 0}% applyPercent(Number(e.target.value), { silent: true })} style={{ width: '100%' }} /> {/* Transform Buttons */} Transformace } 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={deleteSelectedImage} colorScheme="red" variant="outline" /> } size="sm" onClick={resetFilters} colorScheme="gray" 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 */} {/* Apply All Changes Button */} 💡 Filtry jsou pouze náhled. Klikněte pro trvalou aplikaci. )} {/* Crop Modal */} {/* Image Preview Modal */} setIsPreviewOpen(false)} size="6xl" isCentered> {previewImage && ( )} {/* 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;