| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343 |
- import React, { useState, useRef, useEffect } from 'react';
- import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- } from '@/components/ui/dialog';
- import { Button } from '@/components/ui/button';
- import { Input } from '@/components/ui/input';
- import { Label } from '@/components/ui/label';
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
- import { Slider } from '@/components/ui/slider';
- import { Separator } from '@/components/ui/separator';
- import {
- Type,
- Crop,
- Save,
- Loader2,
- Check,
- Plus,
- X
- } from 'lucide-react';
- import { FlowerTextTemplate } from '@/api/entities';
- const loadImage = (url) => new Promise((resolve, reject) => {
- const img = new Image();
- img.crossOrigin = 'Anonymous';
- img.onload = () => resolve(img);
- img.onerror = reject;
- img.src = url;
- });
- export default function ImageCompareModal({
- isOpen,
- onClose,
- generatedImage,
- onSave,
- initialFlowerText = '',
- initialFlowerStyle = 'classic'
- }) {
- const [currentImageDataUrl, setCurrentImageDataUrl] = useState(generatedImage);
- const [mode, setMode] = useState('crop');
- const [textElements, setTextElements] = useState([]);
- const [selectedTextId, setSelectedTextId] = useState(null);
- const [cropArea, setCropArea] = useState({ x: 10, y: 10, width: 80, height: 80 });
- const [isDragging, setIsDragging] = useState(false);
- const [dragType, setDragType] = useState(null);
- const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
- const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
- const [resizeHandle, setResizeHandle] = useState(null);
- const [isSaving, setIsSaving] = useState(false);
- const [isApplyingCrop, setIsApplyingCrop] = useState(false);
- const [templateName, setTemplateName] = useState('');
- const imageContainerRef = useRef(null);
- const isDraggingRef = useRef(false);
- useEffect(() => {
- if (isOpen) {
- setCurrentImageDataUrl(generatedImage);
- setTextElements([
- { id: 1, text: initialFlowerText, x: 50, y: 50, fontSize: 32, color: '#FFFFFF', style: initialFlowerStyle, width: 300 }
- ]);
- setSelectedTextId(1);
- setCropArea({ x: 10, y: 10, width: 80, height: 80 });
- setMode('crop');
- }
- }, [isOpen, generatedImage, initialFlowerText, initialFlowerStyle]);
- const textStyleOptions = [
- { value: 'classic', label: '经典样式' },
- { value: 'modern', label: '现代简约' },
- { value: 'cute', label: '可爱风格' },
- { value: 'elegant', label: '优雅文艺' },
- { value: 'bold', label: '粗体醒目' },
- { value: 'gradient', label: '渐变炫彩' },
- { value: 'neon', label: '霓虹发光' },
- { value: 'vintage', label: '复古怀旧' }
- ];
- const getTextStyle = (style) => {
- const styles = {
- classic: { fontFamily: 'serif', textShadow: '2px 2px 4px rgba(0,0,0,0.5)', fontWeight: 'bold' },
- modern: { fontFamily: 'sans-serif', textShadow: '1px 1px 2px rgba(0,0,0,0.3)', fontWeight: '600', letterSpacing: '1px' },
- cute: { fontFamily: 'cursive', textShadow: '2px 2px 0px #fff, 4px 4px 6px rgba(0,0,0,0.3)', fontWeight: 'bold' },
- elegant: { fontFamily: 'serif', textShadow: '1px 1px 3px rgba(0,0,0,0.4)', fontWeight: '300', fontStyle: 'italic' },
- bold: { fontFamily: 'sans-serif', textShadow: '3px 3px 0px #000, 6px 6px 8px rgba(0,0,0,0.4)', fontWeight: '900', textTransform: 'uppercase' },
- gradient: { fontFamily: 'sans-serif', background: 'linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', textShadow: 'none', fontWeight: 'bold' },
- neon: { fontFamily: 'sans-serif', textShadow: '0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor', fontWeight: 'bold', color: '#00ffff' },
- vintage: { fontFamily: 'serif', textShadow: '2px 2px 0px #8B4513, 4px 4px 6px rgba(0,0,0,0.5)', fontWeight: 'bold', color: '#D2691E' }
- };
- return styles[style] || styles.classic;
- };
- const getRelativePosition = (e) => {
- if (!imageContainerRef.current) return { x: 0, y: 0 };
- const rect = imageContainerRef.current.getBoundingClientRect();
- const x = ((e.clientX - rect.left) / rect.width) * 100;
- const y = ((e.clientY - rect.top) / rect.height) * 100;
- return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) };
- };
- const handleMouseDown = (e, type, id = null, handle = null) => {
- e.preventDefault(); e.stopPropagation();
- setIsDragging(true); setDragType(type); isDraggingRef.current = true;
- const pos = getRelativePosition(e);
- setDragStart(pos);
- if (type === 'text' && id) {
- setSelectedTextId(id);
- const text = textElements.find(t => t.id === id);
- if (text) setDragOffset({ x: pos.x - text.x, y: pos.y - text.y });
- } else if (type === 'crop' && !handle) {
- setDragOffset({ x: pos.x - cropArea.x, y: pos.y - cropArea.y });
- } else if (type === 'crop' && handle) {
- setResizeHandle(handle);
- }
- };
- const handleMouseMove = (e) => {
- if (!isDraggingRef.current) return;
- const pos = getRelativePosition(e);
- if (dragType === 'text') {
- setTextElements(prev => prev.map(t => t.id === selectedTextId ? { ...t, x: pos.x - dragOffset.x, y: pos.y - dragOffset.y } : t));
- } else if (dragType === 'crop') {
- if (resizeHandle) {
- setCropArea(prev => {
- const newArea = { ...prev };
- const dx = pos.x - dragStart.x;
- const dy = pos.y - dragStart.y;
- if (resizeHandle.includes('e')) newArea.width += dx;
- if (resizeHandle.includes('s')) newArea.height += dy;
- if (resizeHandle.includes('w')) { newArea.x += dx; newArea.width -= dx; }
- if (resizeHandle.includes('n')) { newArea.y += dy; newArea.height -= dy; }
- setDragStart(pos);
- return newArea;
- });
- } else {
- setCropArea(prev => ({ ...prev, x: pos.x - dragOffset.x, y: pos.y - dragOffset.y }));
- }
- }
- };
- const handleMouseUp = () => {
- setIsDragging(false); setDragType(null); isDraggingRef.current = false; setResizeHandle(null);
- };
- useEffect(() => {
- window.addEventListener('mousemove', handleMouseMove);
- window.addEventListener('mouseup', handleMouseUp);
- return () => {
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('mouseup', handleMouseUp);
- };
- }, [handleMouseMove, handleMouseUp]);
- const handleApplyCrop = async () => {
- setIsApplyingCrop(true);
- try {
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
- const baseImage = await loadImage(currentImageDataUrl);
- const cropX = (cropArea.x / 100) * baseImage.naturalWidth;
- const cropY = (cropArea.y / 100) * baseImage.naturalHeight;
- const cropWidth = (cropArea.width / 100) * baseImage.naturalWidth;
- const cropHeight = (cropArea.height / 100) * baseImage.naturalHeight;
- canvas.width = cropWidth;
- canvas.height = cropHeight;
- ctx.drawImage(baseImage, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
- const newImageDataUrl = canvas.toDataURL('image/png');
- setCurrentImageDataUrl(newImageDataUrl);
- setMode('text');
- } catch (error) {
- console.error("应用裁剪失败:", error);
- } finally {
- setIsApplyingCrop(false);
- }
- };
- const generateEditedImage = async () => {
- setIsSaving(true);
- try {
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
- const baseImage = await loadImage(currentImageDataUrl);
- canvas.width = baseImage.naturalWidth;
- canvas.height = baseImage.naturalHeight;
- ctx.drawImage(baseImage, 0, 0);
- textElements.forEach(textEl => {
- const style = getTextStyle(textEl.style);
- ctx.font = `${style.fontWeight || 'normal'} ${style.fontStyle || ''} ${textEl.fontSize}px ${style.fontFamily || 'sans-serif'}`;
- ctx.fillStyle = textEl.color;
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- if (style.textShadow) { ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 5; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; }
- const xPos = (textEl.x / 100) * canvas.width;
- const yPos = (textEl.y / 100) * canvas.height;
- const lines = textEl.text.split('\n');
- lines.forEach((line, index) => {
- ctx.fillText(line, xPos, yPos + (index * textEl.fontSize * 1.2));
- });
- ctx.shadowColor = 'transparent';
- });
- const finalImageDataUrl = canvas.toDataURL('image/png');
- onSave({ originalUrl: generatedImage, newImageDataUrl: finalImageDataUrl });
- onClose();
- } catch (error) {
- console.error("生成最终图片失败:", error);
- } finally {
- setIsSaving(false);
- }
- };
- const handleSliderChange = (field, value) => {
- setCropArea(prev => {
- const newArea = { ...prev };
- if (field === 'x') newArea.x = Math.min(value, 100 - prev.width);
- else if (field === 'y') newArea.y = Math.min(value, 100 - prev.height);
- else if (field === 'width') newArea.width = Math.min(value, 100 - prev.x);
- else if (field === 'height') newArea.height = Math.min(value, 100 - prev.y);
- if (newArea.width < 10) newArea.width = 10;
- if (newArea.height < 10) newArea.height = 10;
- return newArea;
- });
- };
- const handleTextChange = (textId, field, value) => {
- setTextElements(prev => prev.map(t => t.id === textId ? { ...t, [field]: value } : t));
- };
- const handleAddText = () => {
- const newText = { id: Date.now(), text: '新文字', x: 50, y: 70, fontSize: 20, color: '#FFFFFF', style: 'classic', width: 150 };
- setTextElements(prev => [...prev, newText]);
- setSelectedTextId(newText.id);
- };
- const handleDeleteText = (textId) => {
- setTextElements(prev => prev.filter(t => t.id !== textId));
- if (selectedTextId === textId) setSelectedTextId(null);
- };
- const handleSaveTemplate = async () => {
- if (!templateName.trim()) { alert('请输入模板名称'); return; }
- await FlowerTextTemplate.create({ name: templateName, textElements: textElements });
- alert('模板保存成功!');
- setTemplateName('');
- };
- const renderMultilineText = (textEl) => {
- const lineHeight = textEl.fontSize * 1.2;
- return (
- <div key={textEl.id}
- className={`absolute select-none transition-all duration-200 ${selectedTextId === textEl.id ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
- style={{ left: `${textEl.x}%`, top: `${textEl.y}%`, fontSize: `${textEl.fontSize}px`, color: textEl.color, transform: 'translate(-50%, -50%)', cursor: 'move', padding: '8px 12px', borderRadius: '6px', width: `${textEl.width}px`, textAlign: 'center', lineHeight: `${lineHeight}px`, whiteSpace: 'pre-wrap', wordBreak: 'break-word', ...getTextStyle(textEl.style) }}
- onMouseDown={(e) => handleMouseDown(e, 'text', textEl.id)}
- onClick={() => setSelectedTextId(textEl.id)}>
- {textEl.text}
- </div>
- );
- };
- const renderCropArea = () => {
- const handles = [
- { id: 'nw', cursor: 'nwse-resize', top: '-4px', left: '-4px' },
- { id: 'ne', cursor: 'nesw-resize', top: '-4px', right: '-4px' },
- { id: 'sw', cursor: 'nesw-resize', bottom: '-4px', left: '-4px' },
- { id: 'se', cursor: 'nwse-resize', bottom: '-4px', right: '-4px' },
- ];
- return (
- <div className="absolute border-2 border-dashed border-white/80 shadow-lg"
- style={{ left: `${cropArea.x}%`, top: `${cropArea.y}%`, width: `${cropArea.width}%`, height: `${cropArea.height}%`, cursor: 'move' }}
- onMouseDown={(e) => handleMouseDown(e, 'crop')}>
- <div className="absolute inset-0 bg-black/20" />
- {handles.map(h => (
- <div key={h.id} className="absolute w-3 h-3 bg-white rounded-full border-2 border-slate-300"
- style={{ cursor: h.cursor, ...h }} onMouseDown={(e) => handleMouseDown(e, 'crop', null, h.id)} />
- ))}
- </div>
- );
- };
- const selectedText = textElements.find(t => t.id === selectedTextId);
- return (
- <Dialog open={isOpen} onOpenChange={onClose}>
- <DialogContent className="max-w-7xl w-full h-[90vh] flex flex-col">
- <DialogHeader><DialogTitle>高级图片编辑</DialogTitle></DialogHeader>
- <div className="flex-1 grid grid-cols-[1fr_320px] gap-4 overflow-hidden">
- <div className="flex flex-col bg-slate-100 rounded-lg p-2 gap-2">
- <div className="flex items-center gap-2">
- <Button variant={mode === 'crop' ? 'default' : 'outline'} onClick={() => setMode('crop')}><Crop className="w-4 h-4 mr-2"/>裁剪</Button>
- <Button variant={mode === 'text' ? 'default' : 'outline'} onClick={() => setMode('text')}><Type className="w-4 h-4 mr-2"/>花字</Button>
- </div>
- <div ref={imageContainerRef} className="flex-1 bg-slate-800/50 rounded-md overflow-hidden relative" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
- <img src={currentImageDataUrl} className="absolute h-full w-full object-contain" alt="Editing" />
- {mode === 'crop' && renderCropArea()}
- {mode === 'text' && textElements.map(renderMultilineText)}
- </div>
- </div>
- <div className="w-80 flex-shrink-0 bg-slate-50 rounded-lg p-4 space-y-4 overflow-y-auto">
- {mode === 'crop' && (
- <>
- <h3 className="font-semibold">裁剪设置</h3>
- <div><Label>X 位置 ({Math.round(cropArea.x)}%)</Label><Slider value={[cropArea.x]} onValueChange={([v]) => handleSliderChange('x', v)} max={100} step={1}/></div>
- <div><Label>Y 位置 ({Math.round(cropArea.y)}%)</Label><Slider value={[cropArea.y]} onValueChange={([v]) => handleSliderChange('y', v)} max={100} step={1}/></div>
- <div><Label>宽度 ({Math.round(cropArea.width)}%)</Label><Slider value={[cropArea.width]} onValueChange={([v]) => handleSliderChange('width', v)} min={10} max={100} step={1}/></div>
- <div><Label>高度 ({Math.round(cropArea.height)}%)</Label><Slider value={[cropArea.height]} onValueChange={([v]) => handleSliderChange('height', v)} min={10} max={100} step={1}/></div>
- <Button onClick={handleApplyCrop} className="w-full bg-blue-600 hover:bg-blue-700" disabled={isApplyingCrop}>
- {isApplyingCrop ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}应用裁剪并编辑花字
- </Button>
- </>
- )}
- {mode === 'text' && (
- <>
- <h3 className="font-semibold">花字编辑</h3>
- <Button onClick={handleAddText} variant="outline" className="w-full"><Plus className="w-4 h-4 mr-2" />添加新文字</Button>
- <Separator />
- {selectedText && (
- <div className="space-y-3">
- <Label>当前编辑: 文字 #{selectedText.id}</Label>
- <div><Label>样式</Label><Select value={selectedText.style} onValueChange={(v) => handleTextChange(selectedTextId, 'style', v)}><SelectTrigger><SelectValue/></SelectTrigger><SelectContent>{textStyleOptions.map(o=><SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}</SelectContent></Select></div>
- <div><Label>字号 (px)</Label><Slider value={[selectedText.fontSize]} onValueChange={([v]) => handleTextChange(selectedTextId, 'fontSize', v)} min={12} max={80} step={1}/></div>
- <div><Label>宽度 (px)</Label><Slider value={[selectedText.width]} onValueChange={([v]) => handleTextChange(selectedTextId, 'width', v)} min={100} max={500} step={10}/></div>
- <div><Label>颜色</Label><Input type="color" value={selectedText.color} onChange={(e) => handleTextChange(selectedTextId, 'color', e.target.value)} className="w-full"/></div>
- <Button variant="destructive" size="sm" onClick={() => handleDeleteText(selectedTextId)}><X className="w-4 h-4 mr-2" />删除当前文字</Button>
- </div>
- )}
- <Separator />
- <div>
- <h4 className="font-semibold mb-2">保存为模板</h4>
- <div className="flex gap-2"><Input value={templateName} onChange={(e) => setTemplateName(e.target.value)} placeholder="模板名称"/><Button onClick={handleSaveTemplate}><Save className="w-4 h-4"/></Button></div>
- </div>
- </>
- )}
- <Separator className="my-4" />
- <Button onClick={generateEditedImage} className="w-full text-base py-3" disabled={isSaving}>
- {isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}应用并保存所有更改
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- );
- }
|