mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
1907 lines
70 KiB
TypeScript
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;
|