ImageCompareModal.jsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import React, { useState, useRef, useEffect } from 'react';
  2. import {
  3. Dialog,
  4. DialogContent,
  5. DialogHeader,
  6. DialogTitle,
  7. } from '@/components/ui/dialog';
  8. import { Button } from '@/components/ui/button';
  9. import { Input } from '@/components/ui/input';
  10. import { Label } from '@/components/ui/label';
  11. import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
  12. import { Slider } from '@/components/ui/slider';
  13. import { Separator } from '@/components/ui/separator';
  14. import {
  15. Type,
  16. Crop,
  17. Save,
  18. Loader2,
  19. Check,
  20. Plus,
  21. X
  22. } from 'lucide-react';
  23. import { FlowerTextTemplate } from '@/api/entities';
  24. const loadImage = (url) => new Promise((resolve, reject) => {
  25. const img = new Image();
  26. img.crossOrigin = 'Anonymous';
  27. img.onload = () => resolve(img);
  28. img.onerror = reject;
  29. img.src = url;
  30. });
  31. export default function ImageCompareModal({
  32. isOpen,
  33. onClose,
  34. generatedImage,
  35. onSave,
  36. initialFlowerText = '',
  37. initialFlowerStyle = 'classic'
  38. }) {
  39. const [currentImageDataUrl, setCurrentImageDataUrl] = useState(generatedImage);
  40. const [mode, setMode] = useState('crop');
  41. const [textElements, setTextElements] = useState([]);
  42. const [selectedTextId, setSelectedTextId] = useState(null);
  43. const [cropArea, setCropArea] = useState({ x: 10, y: 10, width: 80, height: 80 });
  44. const [isDragging, setIsDragging] = useState(false);
  45. const [dragType, setDragType] = useState(null);
  46. const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
  47. const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
  48. const [resizeHandle, setResizeHandle] = useState(null);
  49. const [isSaving, setIsSaving] = useState(false);
  50. const [isApplyingCrop, setIsApplyingCrop] = useState(false);
  51. const [templateName, setTemplateName] = useState('');
  52. const imageContainerRef = useRef(null);
  53. const isDraggingRef = useRef(false);
  54. useEffect(() => {
  55. if (isOpen) {
  56. setCurrentImageDataUrl(generatedImage);
  57. setTextElements([
  58. { id: 1, text: initialFlowerText, x: 50, y: 50, fontSize: 32, color: '#FFFFFF', style: initialFlowerStyle, width: 300 }
  59. ]);
  60. setSelectedTextId(1);
  61. setCropArea({ x: 10, y: 10, width: 80, height: 80 });
  62. setMode('crop');
  63. }
  64. }, [isOpen, generatedImage, initialFlowerText, initialFlowerStyle]);
  65. const textStyleOptions = [
  66. { value: 'classic', label: '经典样式' },
  67. { value: 'modern', label: '现代简约' },
  68. { value: 'cute', label: '可爱风格' },
  69. { value: 'elegant', label: '优雅文艺' },
  70. { value: 'bold', label: '粗体醒目' },
  71. { value: 'gradient', label: '渐变炫彩' },
  72. { value: 'neon', label: '霓虹发光' },
  73. { value: 'vintage', label: '复古怀旧' }
  74. ];
  75. const getTextStyle = (style) => {
  76. const styles = {
  77. classic: { fontFamily: 'serif', textShadow: '2px 2px 4px rgba(0,0,0,0.5)', fontWeight: 'bold' },
  78. modern: { fontFamily: 'sans-serif', textShadow: '1px 1px 2px rgba(0,0,0,0.3)', fontWeight: '600', letterSpacing: '1px' },
  79. cute: { fontFamily: 'cursive', textShadow: '2px 2px 0px #fff, 4px 4px 6px rgba(0,0,0,0.3)', fontWeight: 'bold' },
  80. elegant: { fontFamily: 'serif', textShadow: '1px 1px 3px rgba(0,0,0,0.4)', fontWeight: '300', fontStyle: 'italic' },
  81. bold: { fontFamily: 'sans-serif', textShadow: '3px 3px 0px #000, 6px 6px 8px rgba(0,0,0,0.4)', fontWeight: '900', textTransform: 'uppercase' },
  82. gradient: { fontFamily: 'sans-serif', background: 'linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', textShadow: 'none', fontWeight: 'bold' },
  83. neon: { fontFamily: 'sans-serif', textShadow: '0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor', fontWeight: 'bold', color: '#00ffff' },
  84. vintage: { fontFamily: 'serif', textShadow: '2px 2px 0px #8B4513, 4px 4px 6px rgba(0,0,0,0.5)', fontWeight: 'bold', color: '#D2691E' }
  85. };
  86. return styles[style] || styles.classic;
  87. };
  88. const getRelativePosition = (e) => {
  89. if (!imageContainerRef.current) return { x: 0, y: 0 };
  90. const rect = imageContainerRef.current.getBoundingClientRect();
  91. const x = ((e.clientX - rect.left) / rect.width) * 100;
  92. const y = ((e.clientY - rect.top) / rect.height) * 100;
  93. return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) };
  94. };
  95. const handleMouseDown = (e, type, id = null, handle = null) => {
  96. e.preventDefault(); e.stopPropagation();
  97. setIsDragging(true); setDragType(type); isDraggingRef.current = true;
  98. const pos = getRelativePosition(e);
  99. setDragStart(pos);
  100. if (type === 'text' && id) {
  101. setSelectedTextId(id);
  102. const text = textElements.find(t => t.id === id);
  103. if (text) setDragOffset({ x: pos.x - text.x, y: pos.y - text.y });
  104. } else if (type === 'crop' && !handle) {
  105. setDragOffset({ x: pos.x - cropArea.x, y: pos.y - cropArea.y });
  106. } else if (type === 'crop' && handle) {
  107. setResizeHandle(handle);
  108. }
  109. };
  110. const handleMouseMove = (e) => {
  111. if (!isDraggingRef.current) return;
  112. const pos = getRelativePosition(e);
  113. if (dragType === 'text') {
  114. setTextElements(prev => prev.map(t => t.id === selectedTextId ? { ...t, x: pos.x - dragOffset.x, y: pos.y - dragOffset.y } : t));
  115. } else if (dragType === 'crop') {
  116. if (resizeHandle) {
  117. setCropArea(prev => {
  118. const newArea = { ...prev };
  119. const dx = pos.x - dragStart.x;
  120. const dy = pos.y - dragStart.y;
  121. if (resizeHandle.includes('e')) newArea.width += dx;
  122. if (resizeHandle.includes('s')) newArea.height += dy;
  123. if (resizeHandle.includes('w')) { newArea.x += dx; newArea.width -= dx; }
  124. if (resizeHandle.includes('n')) { newArea.y += dy; newArea.height -= dy; }
  125. setDragStart(pos);
  126. return newArea;
  127. });
  128. } else {
  129. setCropArea(prev => ({ ...prev, x: pos.x - dragOffset.x, y: pos.y - dragOffset.y }));
  130. }
  131. }
  132. };
  133. const handleMouseUp = () => {
  134. setIsDragging(false); setDragType(null); isDraggingRef.current = false; setResizeHandle(null);
  135. };
  136. useEffect(() => {
  137. window.addEventListener('mousemove', handleMouseMove);
  138. window.addEventListener('mouseup', handleMouseUp);
  139. return () => {
  140. window.removeEventListener('mousemove', handleMouseMove);
  141. window.removeEventListener('mouseup', handleMouseUp);
  142. };
  143. }, [handleMouseMove, handleMouseUp]);
  144. const handleApplyCrop = async () => {
  145. setIsApplyingCrop(true);
  146. try {
  147. const canvas = document.createElement('canvas');
  148. const ctx = canvas.getContext('2d');
  149. const baseImage = await loadImage(currentImageDataUrl);
  150. const cropX = (cropArea.x / 100) * baseImage.naturalWidth;
  151. const cropY = (cropArea.y / 100) * baseImage.naturalHeight;
  152. const cropWidth = (cropArea.width / 100) * baseImage.naturalWidth;
  153. const cropHeight = (cropArea.height / 100) * baseImage.naturalHeight;
  154. canvas.width = cropWidth;
  155. canvas.height = cropHeight;
  156. ctx.drawImage(baseImage, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
  157. const newImageDataUrl = canvas.toDataURL('image/png');
  158. setCurrentImageDataUrl(newImageDataUrl);
  159. setMode('text');
  160. } catch (error) {
  161. console.error("应用裁剪失败:", error);
  162. } finally {
  163. setIsApplyingCrop(false);
  164. }
  165. };
  166. const generateEditedImage = async () => {
  167. setIsSaving(true);
  168. try {
  169. const canvas = document.createElement('canvas');
  170. const ctx = canvas.getContext('2d');
  171. const baseImage = await loadImage(currentImageDataUrl);
  172. canvas.width = baseImage.naturalWidth;
  173. canvas.height = baseImage.naturalHeight;
  174. ctx.drawImage(baseImage, 0, 0);
  175. textElements.forEach(textEl => {
  176. const style = getTextStyle(textEl.style);
  177. ctx.font = `${style.fontWeight || 'normal'} ${style.fontStyle || ''} ${textEl.fontSize}px ${style.fontFamily || 'sans-serif'}`;
  178. ctx.fillStyle = textEl.color;
  179. ctx.textAlign = 'center';
  180. ctx.textBaseline = 'middle';
  181. if (style.textShadow) { ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 5; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; }
  182. const xPos = (textEl.x / 100) * canvas.width;
  183. const yPos = (textEl.y / 100) * canvas.height;
  184. const lines = textEl.text.split('\n');
  185. lines.forEach((line, index) => {
  186. ctx.fillText(line, xPos, yPos + (index * textEl.fontSize * 1.2));
  187. });
  188. ctx.shadowColor = 'transparent';
  189. });
  190. const finalImageDataUrl = canvas.toDataURL('image/png');
  191. onSave({ originalUrl: generatedImage, newImageDataUrl: finalImageDataUrl });
  192. onClose();
  193. } catch (error) {
  194. console.error("生成最终图片失败:", error);
  195. } finally {
  196. setIsSaving(false);
  197. }
  198. };
  199. const handleSliderChange = (field, value) => {
  200. setCropArea(prev => {
  201. const newArea = { ...prev };
  202. if (field === 'x') newArea.x = Math.min(value, 100 - prev.width);
  203. else if (field === 'y') newArea.y = Math.min(value, 100 - prev.height);
  204. else if (field === 'width') newArea.width = Math.min(value, 100 - prev.x);
  205. else if (field === 'height') newArea.height = Math.min(value, 100 - prev.y);
  206. if (newArea.width < 10) newArea.width = 10;
  207. if (newArea.height < 10) newArea.height = 10;
  208. return newArea;
  209. });
  210. };
  211. const handleTextChange = (textId, field, value) => {
  212. setTextElements(prev => prev.map(t => t.id === textId ? { ...t, [field]: value } : t));
  213. };
  214. const handleAddText = () => {
  215. const newText = { id: Date.now(), text: '新文字', x: 50, y: 70, fontSize: 20, color: '#FFFFFF', style: 'classic', width: 150 };
  216. setTextElements(prev => [...prev, newText]);
  217. setSelectedTextId(newText.id);
  218. };
  219. const handleDeleteText = (textId) => {
  220. setTextElements(prev => prev.filter(t => t.id !== textId));
  221. if (selectedTextId === textId) setSelectedTextId(null);
  222. };
  223. const handleSaveTemplate = async () => {
  224. if (!templateName.trim()) { alert('请输入模板名称'); return; }
  225. await FlowerTextTemplate.create({ name: templateName, textElements: textElements });
  226. alert('模板保存成功!');
  227. setTemplateName('');
  228. };
  229. const renderMultilineText = (textEl) => {
  230. const lineHeight = textEl.fontSize * 1.2;
  231. return (
  232. <div key={textEl.id}
  233. className={`absolute select-none transition-all duration-200 ${selectedTextId === textEl.id ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
  234. 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) }}
  235. onMouseDown={(e) => handleMouseDown(e, 'text', textEl.id)}
  236. onClick={() => setSelectedTextId(textEl.id)}>
  237. {textEl.text}
  238. </div>
  239. );
  240. };
  241. const renderCropArea = () => {
  242. const handles = [
  243. { id: 'nw', cursor: 'nwse-resize', top: '-4px', left: '-4px' },
  244. { id: 'ne', cursor: 'nesw-resize', top: '-4px', right: '-4px' },
  245. { id: 'sw', cursor: 'nesw-resize', bottom: '-4px', left: '-4px' },
  246. { id: 'se', cursor: 'nwse-resize', bottom: '-4px', right: '-4px' },
  247. ];
  248. return (
  249. <div className="absolute border-2 border-dashed border-white/80 shadow-lg"
  250. style={{ left: `${cropArea.x}%`, top: `${cropArea.y}%`, width: `${cropArea.width}%`, height: `${cropArea.height}%`, cursor: 'move' }}
  251. onMouseDown={(e) => handleMouseDown(e, 'crop')}>
  252. <div className="absolute inset-0 bg-black/20" />
  253. {handles.map(h => (
  254. <div key={h.id} className="absolute w-3 h-3 bg-white rounded-full border-2 border-slate-300"
  255. style={{ cursor: h.cursor, ...h }} onMouseDown={(e) => handleMouseDown(e, 'crop', null, h.id)} />
  256. ))}
  257. </div>
  258. );
  259. };
  260. const selectedText = textElements.find(t => t.id === selectedTextId);
  261. return (
  262. <Dialog open={isOpen} onOpenChange={onClose}>
  263. <DialogContent className="max-w-7xl w-full h-[90vh] flex flex-col">
  264. <DialogHeader><DialogTitle>高级图片编辑</DialogTitle></DialogHeader>
  265. <div className="flex-1 grid grid-cols-[1fr_320px] gap-4 overflow-hidden">
  266. <div className="flex flex-col bg-slate-100 rounded-lg p-2 gap-2">
  267. <div className="flex items-center gap-2">
  268. <Button variant={mode === 'crop' ? 'default' : 'outline'} onClick={() => setMode('crop')}><Crop className="w-4 h-4 mr-2"/>裁剪</Button>
  269. <Button variant={mode === 'text' ? 'default' : 'outline'} onClick={() => setMode('text')}><Type className="w-4 h-4 mr-2"/>花字</Button>
  270. </div>
  271. <div ref={imageContainerRef} className="flex-1 bg-slate-800/50 rounded-md overflow-hidden relative" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
  272. <img src={currentImageDataUrl} className="absolute h-full w-full object-contain" alt="Editing" />
  273. {mode === 'crop' && renderCropArea()}
  274. {mode === 'text' && textElements.map(renderMultilineText)}
  275. </div>
  276. </div>
  277. <div className="w-80 flex-shrink-0 bg-slate-50 rounded-lg p-4 space-y-4 overflow-y-auto">
  278. {mode === 'crop' && (
  279. <>
  280. <h3 className="font-semibold">裁剪设置</h3>
  281. <div><Label>X 位置 ({Math.round(cropArea.x)}%)</Label><Slider value={[cropArea.x]} onValueChange={([v]) => handleSliderChange('x', v)} max={100} step={1}/></div>
  282. <div><Label>Y 位置 ({Math.round(cropArea.y)}%)</Label><Slider value={[cropArea.y]} onValueChange={([v]) => handleSliderChange('y', v)} max={100} step={1}/></div>
  283. <div><Label>宽度 ({Math.round(cropArea.width)}%)</Label><Slider value={[cropArea.width]} onValueChange={([v]) => handleSliderChange('width', v)} min={10} max={100} step={1}/></div>
  284. <div><Label>高度 ({Math.round(cropArea.height)}%)</Label><Slider value={[cropArea.height]} onValueChange={([v]) => handleSliderChange('height', v)} min={10} max={100} step={1}/></div>
  285. <Button onClick={handleApplyCrop} className="w-full bg-blue-600 hover:bg-blue-700" disabled={isApplyingCrop}>
  286. {isApplyingCrop ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}应用裁剪并编辑花字
  287. </Button>
  288. </>
  289. )}
  290. {mode === 'text' && (
  291. <>
  292. <h3 className="font-semibold">花字编辑</h3>
  293. <Button onClick={handleAddText} variant="outline" className="w-full"><Plus className="w-4 h-4 mr-2" />添加新文字</Button>
  294. <Separator />
  295. {selectedText && (
  296. <div className="space-y-3">
  297. <Label>当前编辑: 文字 #{selectedText.id}</Label>
  298. <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>
  299. <div><Label>字号 (px)</Label><Slider value={[selectedText.fontSize]} onValueChange={([v]) => handleTextChange(selectedTextId, 'fontSize', v)} min={12} max={80} step={1}/></div>
  300. <div><Label>宽度 (px)</Label><Slider value={[selectedText.width]} onValueChange={([v]) => handleTextChange(selectedTextId, 'width', v)} min={100} max={500} step={10}/></div>
  301. <div><Label>颜色</Label><Input type="color" value={selectedText.color} onChange={(e) => handleTextChange(selectedTextId, 'color', e.target.value)} className="w-full"/></div>
  302. <Button variant="destructive" size="sm" onClick={() => handleDeleteText(selectedTextId)}><X className="w-4 h-4 mr-2" />删除当前文字</Button>
  303. </div>
  304. )}
  305. <Separator />
  306. <div>
  307. <h4 className="font-semibold mb-2">保存为模板</h4>
  308. <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>
  309. </div>
  310. </>
  311. )}
  312. <Separator className="my-4" />
  313. <Button onClick={generateEditedImage} className="w-full text-base py-3" disabled={isSaving}>
  314. {isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}应用并保存所有更改
  315. </Button>
  316. </div>
  317. </div>
  318. </DialogContent>
  319. </Dialog>
  320. );
  321. }