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