mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,956 @@
|
||||
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,
|
||||
useColorModeValue,
|
||||
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);
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const toolbarBg = useColorModeValue('white', 'gray.800');
|
||||
const toolbarBorder = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// 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
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
bg={bgColor}
|
||||
sx={{
|
||||
'.ql-toolbar': {
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor,
|
||||
bg: hoverBg,
|
||||
},
|
||||
'.ql-container': {
|
||||
fontSize: '16px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
||||
},
|
||||
'.ql-editor': {
|
||||
minHeight: height,
|
||||
maxHeight: '70vh',
|
||||
overflowY: 'auto',
|
||||
'&::-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',
|
||||
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={useColorModeValue('gray.50', 'gray.900')}
|
||||
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;
|
||||
Reference in New Issue
Block a user