"use client"; import React, { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip"; import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; import { imageGenerationService } from "@/services/canvas"; import type { CanvasTextEditPayloadItem } from "@/services/canvas"; import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas"; import { cn } from "@/lib/utils"; import { AngleEditDialog } from "@/components/angle-edit-dialog"; import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { ImageAnnotator } from "@/components/image-annotator"; import type { PickedObject } from "@/store/canvas"; // 已标注的物品标记(本地状态) interface PickedMarker { clickX: number; clickY: number; objectName: string; index: number; } const normalizeTextEditItems = (items: ImageTextEditItem[] | undefined): ImageTextEditItem[] => { if (!items || items.length === 0) return []; return items .map((item, index) => { const originalText = (item.originalText || "").trim(); if (!originalText) return null; return { id: item.id || String(index + 1), originalText, editedText: (item.editedText || item.originalText || "").trim(), confidence: item.confidence, } as ImageTextEditItem; }) .filter((item): item is ImageTextEditItem => !!item); }; const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url); /** * 在图片上绘制标记点,返回带标记的图片 base64 */ async function drawMarkerOnImage( imageUrl: string, clickX: number, clickY: number ): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('无法创建 canvas')); return; } // 压缩图片尺寸(最大 800px) const maxSize = 800; let width = img.width; let height = img.height; if (width > maxSize || height > maxSize) { if (width > height) { height = (height / width) * maxSize; width = maxSize; } else { width = (width / height) * maxSize; height = maxSize; } } canvas.width = width; canvas.height = height; ctx.drawImage(img, 0, 0, width, height); // 计算标记位置 const markerX = clickX * width; const markerY = clickY * height; const markerRadius = Math.max(12, Math.min(width, height) * 0.025); // 绘制红色标记 ctx.beginPath(); ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2); ctx.fillStyle = 'white'; ctx.fill(); ctx.beginPath(); ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2); ctx.fillStyle = '#FF0000'; ctx.fill(); // 绘制十字线 ctx.strokeStyle = '#FF0000'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(markerX - markerRadius * 2, markerY); ctx.lineTo(markerX - markerRadius - 2, markerY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(markerX + markerRadius + 2, markerY); ctx.lineTo(markerX + markerRadius * 2, markerY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(markerX, markerY - markerRadius * 2); ctx.lineTo(markerX, markerY - markerRadius - 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(markerX, markerY + markerRadius + 2); ctx.lineTo(markerX, markerY + markerRadius * 2); ctx.stroke(); resolve(canvas.toDataURL('image/jpeg', 0.85)); }; img.onerror = () => reject(new Error('图片加载失败')); img.src = imageUrl; }); } interface ImageNodeProps { node: CanvasNode; isSelected: boolean; } export function ImageNode({ node, isSelected }: ImageNodeProps) { const data = node.data as ImageNodeData; const [isHovered, setIsHovered] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [angleDialogOpen, setAngleDialogOpen] = useState(false); const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); const [resizeDialogOpen, setResizeDialogOpen] = useState(false); const [resizeWidth, setResizeWidth] = useState(1024); const [resizeHeight, setResizeHeight] = useState(1024); const [isUploading, setIsUploading] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [textEditPanelOpen, setTextEditPanelOpen] = useState(false); const [textEditItems, setTextEditItems] = useState( () => normalizeTextEditItems(data.textEditItems), ); const [textEditError, setTextEditError] = useState(null); const [isDetectingText, setIsDetectingText] = useState(false); const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); const [deletedTextItems, setDeletedTextItems] = useState>([]); // 文件输入的 ref const fileInputRef = useRef(null); const containerRef = useRef(null); const updateNode = useCanvasStore((state) => state.updateNode); const deleteNode = useCanvasStore((state) => state.deleteNode); const addNode = useCanvasStore((state) => state.addNode); const addPendingChatAttachment = useCanvasStore((state) => state.addPendingChatAttachment); const setPickerMode = useCanvasStore((state) => state.setPickerMode); // 🆕 物品拾取相关状态 const isPickerMode = useCanvasStore((state) => state.isPickerMode); const pickerTargetNodeId = useCanvasStore((state) => state.pickerTargetNodeId); const isPickerLoading = useCanvasStore((state) => state.isPickerLoading); const setPickerLoading = useCanvasStore((state) => state.setPickerLoading); const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject); // 当前节点是否处于拾取模式 const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id; // 当前正在识别的点击位置 const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null); // 退出拾取模式时清空状态 useEffect(() => { if (!isThisNodePicking) { setCurrentClick(null); } }, [isThisNodePicking]); useEffect(() => { if (!textEditPanelOpen) { setTextEditItems(normalizeTextEditItems(data.textEditItems)); setDeletedTextItems([]); setTextEditError(null); } }, [data.textEditItems, textEditPanelOpen]); useEffect(() => { if (!isSelected) { setTextEditPanelOpen(false); } }, [isSelected]); // 🆕 处理图片点击标注 const handlePickerClick = useCallback(async (e: React.MouseEvent) => { if (!isThisNodePicking || isPickerLoading || !data.src) return; e.stopPropagation(); e.preventDefault(); const img = e.currentTarget; const rect = img.getBoundingClientRect(); // 计算相对坐标 (0-1) const clickX = (e.clientX - rect.left) / rect.width; const clickY = (e.clientY - rect.top) / rect.height; setCurrentClick({ x: clickX, y: clickY }); setPickerLoading(true); try { // 在图片上绘制红色标记点 const markedImageBase64 = await drawMarkerOnImage(data.src, clickX, clickY); const response = await fetch('/api/pick-object', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageData: markedImageBase64, clickX, clickY, }), }); if (!response.ok) throw new Error('识别失败'); const result = await response.json(); if (result.success && result.object) { const picked: PickedObject = { imageUrl: data.src, imageNodeId: node.id, clickX, clickY, objectName: result.object.name, objectPosition: result.object.position, confidence: result.object.confidence, boundingBox: result.object.boundingBox, }; addPendingPickedObject(picked); // 标注成功后自动关闭拾取模式,直接生成标签 setPickerMode(false); setCurrentClick(null); toast.success(`已添加: ${result.object.name}`); } else { toast.error(result.error || '未能识别物品'); setCurrentClick(null); } } catch (error) { console.error('物品拾取失败:', error); toast.error('识别失败,请重试'); setCurrentClick(null); } finally { setPickerLoading(false); } }, [isThisNodePicking, isPickerLoading, data.src, node.id, setPickerLoading, addPendingPickedObject, setPickerMode]); // 🆕 取消标注 const handleCancelPicking = useCallback(() => { setPickerMode(false); }, [setPickerMode]); // 获取工具栏位置 - 在渲染时直接计算 const getToolbarPosition = () => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); return { top: rect.top - 50, left: rect.left + rect.width / 2, }; } return { top: 0, left: 0 }; }; const getTextPanelPosition = () => { if (!containerRef.current || typeof window === "undefined") { return { top: 0, left: 0 }; } const rect = containerRef.current.getBoundingClientRect(); const panelWidth = 360; const spacing = 12; const maxTop = Math.max(12, window.innerHeight - 460); let left = rect.right + spacing; if (left + panelWidth > window.innerWidth - 12) { left = Math.max(12, rect.left - panelWidth - spacing); } const top = Math.min(Math.max(12, rect.top), maxTop); return { top, left }; }; const detectTextItems = useCallback( async (forceRefresh: boolean = false) => { if (!data.src) { toast.error("请先上传图片"); return; } if (!isHttpImageUrl(data.src)) { toast.error("仅支持 URL 图片,不允许 base64 传输"); return; } if (isDetectingText) { return; } if (!forceRefresh && data.textEditSource === data.src) { const cached = normalizeTextEditItems(data.textEditItems); if (cached.length > 0) { setTextEditItems(cached); setDeletedTextItems([]); setTextEditError(null); return; } } setIsDetectingText(true); setTextEditError(null); try { const result = await imageGenerationService.detectCanvasText(data.src); if (!result.success) { throw new Error(result.error || "文字识别失败"); } const detectedItems: ImageTextEditItem[] = (result.data?.items || []) .map((item, index) => { const text = (item.text || "").trim(); if (!text) return null; return { id: item.id || String(index + 1), originalText: text, editedText: text, confidence: item.confidence, } as ImageTextEditItem; }) .filter((item): item is ImageTextEditItem => !!item); setTextEditItems(detectedItems); setDeletedTextItems([]); updateNode(node.id, { data: { ...data, textEditItems: detectedItems, textEditSource: data.src, textEditUpdatedAt: new Date().toISOString(), }, }); if (detectedItems.length === 0) { toast.info("未识别到可编辑文字"); } else { toast.success(`已识别 ${detectedItems.length} 段文字`); } } catch (error) { const message = error instanceof Error ? error.message : "文字识别失败"; setTextEditError(message); toast.error(message); } finally { setIsDetectingText(false); } }, [data, isDetectingText, node.id, updateNode], ); const handleOpenTextEdit = useCallback(async () => { setTextEditPanelOpen(true); setTextEditError(null); const cached = normalizeTextEditItems(data.textEditItems); if (cached.length > 0 && data.textEditSource === data.src) { setTextEditItems(cached); setDeletedTextItems([]); return; } await detectTextItems(true); }, [data.src, data.textEditItems, data.textEditSource, detectTextItems]); const handleTextItemChange = (id: string, value: string) => { setTextEditItems((prev) => prev.map((item) => (item.id === id ? { ...item, editedText: value } : item)), ); }; const handleRemoveTextItem = (id: string) => { setTextEditItems((prev) => { const target = prev.find((item) => item.id === id); if (target?.originalText) { setDeletedTextItems((deletedPrev) => { if (deletedPrev.some((item) => item.id === id)) return deletedPrev; return [...deletedPrev, { id, originalText: target.originalText }]; }); } return prev.filter((item) => item.id !== id); }); }; const handleApplyTextEdit = useCallback(async () => { if (!data.src) { toast.error("请先上传图片"); return; } if (!isHttpImageUrl(data.src)) { toast.error("仅支持 URL 图片,不允许 base64 传输"); return; } const changedItems: CanvasTextEditPayloadItem[] = textEditItems .map((item) => { const editedText = item.editedText.trim(); const action: "replace" | "delete" = editedText.length === 0 ? "delete" : "replace"; return { id: item.id, originalText: item.originalText.trim(), editedText, action, }; }) .filter( (item) => item.originalText.length > 0 && ((item.action === "delete") || (item.editedText && item.editedText.length > 0 && item.originalText !== item.editedText)), ); const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems .map((item) => ({ id: item.id, originalText: item.originalText.trim(), action: "delete" as const, })) .filter((item) => item.originalText.length > 0); const requestItems = [...changedItems, ...removedItems]; if (requestItems.length === 0) { toast.info("请先修改或删除至少一段文字"); return; } if (isApplyingTextEdit) { return; } setIsApplyingTextEdit(true); setIsProcessing(true); setTextEditError(null); const newNodeId = addNode({ type: "image", position: { x: node.position.x + node.size.width + 20, y: node.position.y, }, size: { width: node.size.width, height: node.size.height }, data: { src: "", status: "generating", prompt: "文字替换处理中...", originalSrc: data.src, }, connections: [node.id], }); try { const result = await imageGenerationService.editCanvasText({ imageUrl: data.src, items: requestItems, }); if (!result.success || !result.data?.originalUrl) { throw new Error(result.error || "文字替换失败"); } updateNode(newNodeId, { data: { src: result.data.originalUrl, status: "completed", prompt: "文字替换结果", originalSrc: data.src, model: result.data.model, }, }); updateNode(node.id, { data: { ...data, textEditItems, textEditSource: data.src, textEditUpdatedAt: new Date().toISOString(), }, }); setTextEditPanelOpen(false); setDeletedTextItems([]); toast.success("文字替换已完成"); } catch (error) { const message = error instanceof Error ? error.message : "文字替换失败"; setTextEditError(message); updateNode(newNodeId, { data: { src: "", status: "failed", prompt: "文字替换失败", originalSrc: data.src, }, }); toast.error(message); } finally { setIsApplyingTextEdit(false); setIsProcessing(false); } }, [addNode, data, deletedTextItems, isApplyingTextEdit, node.id, node.position.x, node.position.y, node.size.height, node.size.width, textEditItems, updateNode]); // 触发文件选择 const handleSelectFile = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); fileInputRef.current?.click(); }; // 处理图片上传 - 上传到存储桶,不使用 Base64 const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // 验证文件类型 if (!file.type.startsWith('image/')) { toast.error('请上传图片文件'); return; } // 验证文件大小 (20MB) if (file.size > 20 * 1024 * 1024) { toast.error('图片大小不能超过 20MB'); return; } setIsUploading(true); // 先显示上传中状态 updateNode(node.id, { data: { ...data, status: 'generating' }, }); try { // 上传到存储桶 const result = await imageGenerationService.uploadImage(file); if (result.success && result.data?.url) { updateNode(node.id, { data: { ...data, src: result.data.url, status: 'completed' }, }); toast.success('图片上传成功'); } else { updateNode(node.id, { data: { ...data, status: 'failed' }, }); toast.error(result.error || '图片上传失败'); } } catch (error) { console.error('图片上传失败:', error); updateNode(node.id, { data: { ...data, status: 'failed' }, }); toast.error('图片上传失败,请重试'); } finally { setIsUploading(false); } // 重置 input e.target.value = ''; }; // 获取操作的中文名称 const getActionName = (action: string): string => { const names: Record = { 'rmbg': '抠图', 'watermark': '去水印', 'enhance': '变清晰', 'extend': '图片扩展', 'resize': '尺寸调整', 'angle': '角度编辑', 'scene-fusion': '详情替换', 'annotate': '图片标注', 'text-edit': '编辑文字', }; return names[action] || action; }; // 处理图片编辑操作 - 集成我们现有的 API const handleAction = async (action: string) => { if (!data.src) { toast.error("请先上传图片"); return; } if (isProcessing) { toast.info("正在处理中,请稍候..."); return; } // 对话框类型的操作 if (action === 'angle') { setAngleDialogOpen(true); return; } if (action === 'scene-fusion') { setSceneFusionDialogOpen(true); return; } if (action === 'resize') { setResizeDialogOpen(true); return; } if (action === 'annotate') { setAnnotatorOpen(true); return; } if (action === 'text-edit') { await handleOpenTextEdit(); return; } const actionName = getActionName(action); setIsProcessing(true); // 先创建一个"处理中"状态的节点,显示在原图右侧 const newNodeId = addNode({ type: 'image', position: { x: node.position.x + node.size.width + 20, y: node.position.y }, size: { width: node.size.width, height: node.size.height }, data: { src: '', // 暂时没有图片 status: 'generating', prompt: `${actionName}处理中...`, originalSrc: data.src, }, connections: [node.id], }); let result; try { switch (action) { case 'rmbg': result = await imageGenerationService.removeBackground(data.src); break; case 'watermark': result = await imageGenerationService.removeWatermark(data.src); break; case 'enhance': result = await imageGenerationService.enhanceImage(data.src); break; case 'extend': result = await imageGenerationService.extendImage(data.src); break; default: // 删除创建的节点 if (newNodeId) deleteNode(newNodeId); return; } if (result?.success && result.data?.url) { // 更新节点为完成状态 if (newNodeId) { updateNode(newNodeId, { data: { src: result.data.url, status: 'completed', prompt: `${actionName}结果`, originalSrc: data.src, }, }); } toast.success(`${actionName}处理完成`); } else { // 更新节点为失败状态 if (newNodeId) { updateNode(newNodeId, { data: { src: '', status: 'failed', prompt: `${actionName}失败`, originalSrc: data.src, }, }); } toast.error(result?.error || "处理失败"); } } catch (error) { console.error(`${action} 处理失败:`, error); // 更新节点为失败状态 if (newNodeId) { updateNode(newNodeId, { data: { src: '', status: 'failed', prompt: `${actionName}失败`, originalSrc: data.src, }, }); } toast.error("处理失败,请重试"); } finally { setIsProcessing(false); } }; // 角度编辑完成回调 const handleAngleEditComplete = (resultUrl: string) => { // 创建新的图片节点显示结果 addNode({ type: 'image', position: { x: node.position.x + node.size.width + 20, y: node.position.y }, size: { width: node.size.width, height: node.size.height }, data: { src: resultUrl, status: 'completed', prompt: '角度编辑结果', originalSrc: data.src, }, connections: [node.id], }); }; // 图片标注完成回调 const handleAnnotateComplete = async (annotatedImageUrl: string) => { setAnnotatorOpen(false); // 如果仍然是 base64 格式,先上传到服务器(ImageAnnotator 应该已经处理了,这里作为保护) let finalUrl = annotatedImageUrl; if (annotatedImageUrl.startsWith("data:")) { try { toast.info("正在上传图片..."); const response = await fetch(annotatedImageUrl); const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `annotated-${Date.now()}.png`); const uploadResponse = await fetch("/api/files/upload", { method: "POST", body: formData, }); if (uploadResponse.ok) { const result = await uploadResponse.json(); finalUrl = result.url; } else { toast.error("上传失败"); return; } } catch (error) { console.error("上传标注图片失败:", error); toast.error("上传失败"); return; } } // 更新当前节点,标记为已标注图片 // 标注后的图片会带有视觉标记(箭头、圆圈、文字等) // 这些标记指示了用户希望 AI 重点关注/修改的区域 updateNode(node.id, { data: { ...data, src: finalUrl, hasAnnotations: true, // 🆕 标记为已标注 originalSrc: data.originalSrc || data.src, // 保留原始图片 prompt: data.prompt ? `${data.prompt} (已标注)` : '已标注图片', }, }); toast.success('标注已保存!发送对话时将重点处理标注区域'); }; // 尺寸调整执行 const handleResize = async () => { if (!data.src) return; if (isProcessing) { toast.info("正在处理中,请稍候..."); return; } setResizeDialogOpen(false); setIsProcessing(true); // 创建处理中节点 const newNodeId = addNode({ type: 'image', position: { x: node.position.x + node.size.width + 20, y: node.position.y }, size: { width: resizeWidth / 3, height: resizeHeight / 3 }, // 按比例显示 data: { src: '', status: 'generating', prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`, originalSrc: data.src, }, connections: [node.id], }); try { const result = await imageGenerationService.resizeImage(data.src, resizeWidth, resizeHeight); if (result?.success && result.data?.url) { updateNode(newNodeId, { data: { src: result.data.url, status: 'completed', prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`, originalSrc: data.src, }, }); toast.success('尺寸调整完成'); } else { updateNode(newNodeId, { data: { src: '', status: 'failed', prompt: '尺寸调整失败', originalSrc: data.src, }, }); toast.error(result?.error || '尺寸调整失败'); } } catch (error) { console.error('尺寸调整失败:', error); updateNode(newNodeId, { data: { src: '', status: 'failed', prompt: '尺寸调整失败', originalSrc: data.src, }, }); toast.error('尺寸调整失败'); } finally { setIsProcessing(false); } }; // 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转) const handleDownload = async () => { if (!data.src) return; try { toast.info('正在准备下载...'); // 通过 fetch 获取图片数据 const response = await fetch(data.src); const blob = await response.blob(); // 创建 Blob URL const blobUrl = URL.createObjectURL(blob); // 创建下载链接 const link = document.createElement('a'); link.href = blobUrl; // 从 URL 中提取文件扩展名,默认使用 png const urlParts = data.src.split('.'); const ext = urlParts[urlParts.length - 1]?.split('?')[0] || 'png'; link.download = `image-${node.id}.${ext}`; // 触发下载 document.body.appendChild(link); link.click(); document.body.removeChild(link); // 释放 Blob URL URL.revokeObjectURL(blobUrl); toast.success('下载成功'); } catch (error) { console.error('下载失败:', error); toast.error('下载失败,请重试'); // 降级方案:直接打开图片 window.open(data.src, '_blank'); } }; return ( <> {/* 选中时在图片上方显示标题和尺寸 */} {isSelected && data.src && (
{data.prompt || '图片'}
{node.size.width} × {node.size.height}
)}
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {/* 图片内容 - 使用 object-contain 保持原始比例 */} {data.src ? (
{data.prompt { if (!isThisNodePicking) { e.stopPropagation(); setFullscreenPreviewOpen(true); } }} /> {/* 🆕 拾取模式:当前点击位置(正在识别) */} {isThisNodePicking && currentClick && (
)} {/* 🆕 拾取模式:状态栏 */} {isThisNodePicking && (
{isPickerLoading ? '正在识别...' : '点击标注物品'}
)}
) : data.status === 'generating' ? (
生成中...
) : data.status === 'failed' ? (
生成失败
) : (
点击上传图片 {/* 隐藏的文件输入 */} {/* 选择文件按钮 */}
)} {/* 处理中遮罩 */} {isProcessing && (
处理中...
)} {/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */} {/* 悬浮时的更多操作按钮 (右上角) */} {isHovered && data.src && !isProcessing && !isSelected && (
handleAction('rmbg')}> 抠图 handleAction('watermark')}> 去水印 handleAction('enhance')}> 变清晰 handleAction('extend')}> 图片扩展 handleAction('resize')}> 尺寸调整 handleAction('angle')}> 角度编辑 handleAction('scene-fusion')}> 详情替换 handleAction('text-edit')}> 编辑文字 下载 deleteNode(node.id)} > 删除
)} {/* 提示词显示 */} {data.prompt && isHovered && !isProcessing && (

{data.prompt}

)}
{/* 角度编辑对话框 */} {data.src && ( )} {/* 详情替换对话框 */} {/* 尺寸调整对话框 */} 尺寸调整
setResizeWidth(parseInt(e.target.value) || 1024)} min={100} max={4096} />
setResizeHeight(parseInt(e.target.value) || 1024)} min={100} max={4096} />
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */} {isSelected && data.src && typeof document !== 'undefined' && createPortal(
e.stopPropagation()} > {/* 添加到对话 */} 添加到对话 {/* 🆕 物品拾取 */} 拾取物品 {/* 分隔线 */}
{/* 放大/高清 */} 高清放大 {/* 移除背景/抠图 */} 移除背景 {/* 去水印 */} 去水印 {/* 场景融合 */} 场景融合 {/* 文字编辑 */} 编辑文字 {/* 角度编辑 */} 角度编辑 {/* 扩展 */} 图片扩展 {/* 尺寸调整 */} 尺寸调整 {/* 图片标注 */} 标注重点区域 {/* 下载 */} 下载图片 {/* 删除 */} 删除
, document.body )} {/* 文字编辑面板(显示在图片节点旁边) */} {isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal(
e.stopPropagation()} >
编辑文字

自动调用 Qwen3VL 识别图片文字。确认后将调用火山生图模型替换文字,并尽可能保持原字体形状。

{isDetectingText && (
正在识别图片文字...
)} {textEditError && !isDetectingText && (
{textEditError}
)} {!isDetectingText && textEditItems.length === 0 && !textEditError && (
未识别到可编辑文字,可点击“重新识别”再试一次。
)} {textEditItems.map((item, index) => (
文本 {index + 1} {typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
原文:{item.originalText}
handleTextItemChange(item.id, e.target.value)} placeholder="输入替换后的文字" />
))} {deletedTextItems.length > 0 && (
已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
)}
, document.body )} {/* 图片标注器 */} {annotatorOpen && data.src && ( setAnnotatorOpen(false)} /> )} {/* 双击放大预览弹窗 */} {fullscreenPreviewOpen && data.src && createPortal(
setFullscreenPreviewOpen(false)} > {/* 关闭按钮 */} {/* 图片 */} {data.prompt e.stopPropagation()} /> {/* 底部提示 */}
点击任意位置关闭
, document.body )} ); }