Files
MyClub/frontend/src/components/common/CustomRichEditor.tsx
T
Tomas Dvorak 16e4533202 dev day #75
2025-10-29 21:20:16 +01:00

1907 lines
70 KiB
TypeScript

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<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 toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const selectedImageIdRef = useRef<string | null>(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<string | null>(null);
const [cropFile, setCropFile] = useState<File | 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 [cropProcessing, setCropProcessing] = useState(false);
const imgRef = useRef<HTMLImageElement | null>(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<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 });
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [imageWidth, setImageWidth] = useState<number>(0);
const [manualWidth, setManualWidth] = useState<string>('');
const [widthPercent, setWidthPercent] = useState<number>(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<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 {
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 (
<Box>
{/* Editor Controls */}
{!readOnly && onImageUpload && (
<HStack mb={2} spacing={2} justify="flex-start" flexWrap="wrap">
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
<Text fontSize="xs" color="gray.500">
nebo použijte tlačítko obrázku v nástrojové liště
</Text>
</HStack>
)}
<Box
position="relative"
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="visible"
bg={bgColor}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
borderColor: borderColor,
bg: hoverBg,
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
padding: '12px',
'& button': {
color: 'gray.700 !important',
width: '32px !important',
height: '32px !important',
borderRadius: '6px',
transition: 'all 0.2s',
'&:hover': {
background: 'rgba(49, 130, 206, 0.1) !important',
transform: 'scale(1.05)',
},
'&.ql-active': {
background: 'rgba(49, 130, 206, 0.2) !important',
color: '#3182ce !important',
},
},
'& .ql-stroke': {
stroke: 'gray.700 !important',
},
'& .ql-fill': {
fill: 'gray.700 !important',
},
'& .ql-active .ql-stroke': {
stroke: '#3182ce !important',
},
'& .ql-active .ql-fill': {
fill: '#3182ce !important',
},
'& .ql-picker': {
color: 'gray.700 !important',
},
'& .ql-picker-label': {
borderRadius: '6px',
padding: '4px 8px',
transition: 'all 0.2s',
'&:hover': {
background: 'rgba(49, 130, 206, 0.1) !important',
},
},
'& .ql-picker-options': {
background: 'white',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
padding: '8px',
},
},
'.ql-container': {
fontSize: '16px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
bg: 'white',
},
'.ql-editor': {
minHeight: height,
maxHeight: '70vh',
overflowY: 'auto',
bg: 'white !important',
color: '#1a202c !important',
padding: '16px',
lineHeight: '1.6',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
bg: 'gray.100',
},
'&::-webkit-scrollbar-thumb': {
bg: 'gray.400',
borderRadius: '4px',
},
'h1, h2, h3, h4, h5, h6': {
color: '#1a202c !important',
},
'h1': {
fontSize: '2em !important',
fontWeight: 'bold !important',
marginTop: '0.67em !important',
marginBottom: '0.67em !important',
lineHeight: '1.2 !important',
},
'h2': {
fontSize: '1.5em !important',
fontWeight: 'bold !important',
marginTop: '0.83em !important',
marginBottom: '0.83em !important',
lineHeight: '1.3 !important',
},
'h3': {
fontSize: '1.17em !important',
fontWeight: 'bold !important',
marginTop: '1em !important',
marginBottom: '1em !important',
lineHeight: '1.4 !important',
},
'p, li, span, div': {
color: '#2d3748 !important',
},
'strong, b': {
color: '#1a202c !important',
fontWeight: 'bold !important',
},
'a': {
color: '#3182ce !important',
textDecoration: 'underline',
},
'blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: '#4a5568 !important',
fontStyle: 'italic',
backgroundColor: '#f7fafc',
padding: '12px 16px',
borderRadius: '4px',
},
'code': {
backgroundColor: '#f7fafc',
padding: '2px 6px',
borderRadius: '3px',
color: '#e53e3e !important',
fontFamily: 'monospace',
},
'pre': {
backgroundColor: '#2d3748',
color: '#f7fafc !important',
padding: '16px',
borderRadius: '6px',
overflow: 'auto',
},
'ul, ol': {
paddingLeft: '1.5em',
margin: '0.5em 0',
},
img: {
cursor: 'pointer',
maxWidth: '100%',
height: 'auto',
display: 'block',
margin: '12px 0',
transition: 'all 0.2s ease',
borderRadius: '4px',
userSelect: 'none',
pointerEvents: 'auto',
WebkitUserDrag: 'none',
userDrag: 'none',
'&:hover': {
opacity: 0.95,
transform: 'scale(1.01)',
},
},
},
'.ql-editor.ql-blank::before': {
color: '#a0aec0 !important',
fontStyle: 'italic',
},
// Prevent white and very light text colors
'.ql-editor [style*="color: rgb(255, 255, 255)"]': {
color: '#1a202c !important',
},
'.ql-editor [style*="color: white"]': {
color: '#1a202c !important',
},
'.ql-editor [style*="color: #fff"]': {
color: '#1a202c !important',
},
'.ql-editor [style*="color: #ffffff"]': {
color: '#1a202c !important',
},
'.ql-editor [style*="color: rgb(255,255,255)"]': {
color: '#1a202c !important',
},
}}
>
{isMounted && (
<ReactQuill
theme="snow"
value={value}
onChange={handleChange}
readOnly={readOnly}
placeholder={placeholder}
ref={quillRef}
modules={quillModules}
/>
)}
</Box>
{!readOnly && (
<Text fontSize="xs" color="gray.500" mt={2}>
💡 <strong>Tip:</strong> 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í
</Text>
)}
{/* Floating Image Editing Toolbar */}
{showImageToolbar && selectedImageElement && !readOnly && (
<Box
ref={toolbarRef}
position="absolute"
top={`${toolbarPosition.top}px`}
left={`${toolbarPosition.left}px`}
bg={toolbarBg}
borderWidth="2px"
borderColor="blue.400"
borderRadius="lg"
boxShadow="2xl"
p={3}
zIndex={9999}
minW="320px"
maxW="380px"
maxH="80vh"
overflowY="auto"
pointerEvents="auto"
onClick={(e) => { 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',
},
}}
>
<VStack align="stretch" spacing={3}>
{/* Toolbar Header */}
<HStack
justify="space-between"
onMouseDown={(e) => {
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"
>
<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>
{/* Alignment Buttons */}
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="semibold" color="gray.600">Zarovnání</Text>
<HStack spacing={2} justify="stretch">
<Tooltip label="Zarovnat vlevo">
<IconButton
aria-label="Align left"
icon={<AlignLeft size={16} />}
size="sm"
onClick={() => alignImage('left')}
colorScheme="teal"
variant="outline"
flex={1}
/>
</Tooltip>
<Tooltip label="Zarovnat na střed">
<IconButton
aria-label="Align center"
icon={<AlignCenter size={16} />}
size="sm"
onClick={() => alignImage('center')}
colorScheme="teal"
variant="outline"
flex={1}
/>
</Tooltip>
<Tooltip label="Zarovnat vpravo">
<IconButton
aria-label="Align right"
icon={<AlignRight size={16} />}
size="sm"
onClick={() => alignImage('right')}
colorScheme="teal"
variant="outline"
flex={1}
/>
</Tooltip>
</HStack>
</VStack>
{/* Width Control */
}
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="semibold" color="gray.600">Šířka obrázku</Text>
<HStack spacing={2}>
<Input
size="sm"
type="number"
value={manualWidth}
onChange={(e) => setManualWidth(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); applyManualWidth(); } }}
placeholder="Šířka v px"
min={50}
/>
<Button
size="sm"
colorScheme="blue"
onClick={applyManualWidth}
minW="80px"
>
Nastavit
</Button>
</HStack>
<Text fontSize="xs" color="gray.500">Aktuální: {imageWidth}px ({widthPercent || 0}%)</Text>
<HStack spacing={2}>
<Button size="xs" variant="outline" onClick={() => applyPercent(25, { silent: true })}>25%</Button>
<Button size="xs" variant="outline" onClick={() => applyPercent(50, { silent: true })}>50%</Button>
<Button size="xs" variant="outline" onClick={() => applyPercent(75, { silent: true })}>75%</Button>
<Button size="xs" variant="outline" onClick={() => applyPercent(100, { silent: true })}>100%</Button>
<Button size="xs" colorScheme="gray" variant="ghost" onClick={resetWidth}>Reset</Button>
</HStack>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Šířka (%)</FormLabel>
<Text fontSize="xs" color="gray.500">{widthPercent || 0}%</Text>
</HStack>
<input
type="range"
min="5"
max="100"
value={widthPercent || 0}
onChange={(e) => applyPercent(Number(e.target.value), { silent: true })}
style={{ width: '100%' }}
/>
</FormControl>
</VStack>
{/* Transform Buttons */}
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="semibold" color="gray.600">Transformace</Text>
<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="Smazat obrázek">
<IconButton
aria-label="Delete image"
icon={<Trash2 size={16} />}
size="sm"
onClick={deleteSelectedImage}
colorScheme="red"
variant="outline"
/>
</Tooltip>
<Tooltip label="Resetovat filtry">
<IconButton
aria-label="Reset filters"
icon={<RotateCcw size={16} />}
size="sm"
onClick={resetFilters}
colorScheme="gray"
variant="outline"
/>
</Tooltip>
</HStack>
</VStack>
{/* 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>
{/* Apply All Changes Button */}
<Button
colorScheme="green"
size="md"
width="full"
onClick={applyFiltersToBackend}
isLoading={isApplyingFilters}
loadingText="Zpracování..."
leftIcon={<Check size={16} />}
>
Aplikovat všechny změny
</Button>
<Text fontSize="xs" color="gray.500" textAlign="center">
💡 Filtry jsou pouze náhled. Klikněte pro trvalou aplikaci.
</Text>
</VStack>
</Box>
)}
{/* Crop Modal */}
{/* Image Preview Modal */}
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(4px)" />
<ModalContent maxW="90vw" maxH="90vh" bg="transparent" boxShadow="none">
<ModalCloseButton color="white" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} />
<ModalBody display="flex" alignItems="center" justifyContent="center" p={0}>
{previewImage && (
<ChakraImage
src={previewImage}
alt="Preview"
maxW="100%"
maxH="90vh"
objectFit="contain"
borderRadius="md"
boxShadow="2xl"
/>
)}
</ModalBody>
</ModalContent>
</Modal>
{/* 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="gray.50"
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); setCropFile(null); }} isDisabled={cropProcessing}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={confirmCropAndInsert} isLoading={cropProcessing} loadingText="Zpracování...">
Oříznout a vložit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
);
};
export default CustomRichEditor;