Your Name 9276e6c35c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: accept kimi direct detail dsl output
2026-03-22 12:42:33 +08:00

3308 lines
114 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote, Scan } 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 { Textarea } from "@/components/ui/textarea";
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, NormalizedCropRect } from "@/services/canvas";
import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas";
import { cn } from "@/lib/utils";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { ImageAnnotator } from "@/components/image-annotator";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import type { PickedObject } from "@/store/canvas";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import {
normalizeDetailUiSubjectAnalysis,
type DetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis";
// 已标注的物品标记(本地状态)
interface PickedMarker {
clickX: number;
clickY: number;
objectName: string;
index: number;
}
interface ImageDisplayMetrics {
offsetX: number;
offsetY: number;
renderedWidth: number;
renderedHeight: number;
naturalWidth: number;
naturalHeight: number;
}
interface BoxCutoutPoint {
x: number;
y: number;
}
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
const BOX_CUTOUT_MIN_EDGE_PX = 24;
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const createNormalizedRect = (
start: BoxCutoutPoint,
end: BoxCutoutPoint
): NormalizedCropRect => {
const x = Math.min(start.x, end.x);
const y = Math.min(start.y, end.y);
const right = Math.max(start.x, end.x);
const bottom = Math.max(start.y, end.y);
return {
x,
y,
width: clampNumber(right - x, 0, 1),
height: clampNumber(bottom - y, 0, 1),
};
};
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);
const normalizeMediaUrl = (url: string): string => {
const raw = String(url || "").trim();
if (!raw) return "";
try {
const parsed = new URL(raw);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
} catch {
return raw
.split("#")[0]
.split("?")[0]
.replace(/\/+$/, "")
.toLowerCase();
}
};
const normalizeImageIdentityUrl = (url: string): string => {
const raw = String(url || "").trim();
if (!raw) return "";
const canonical = inferOriginalImageUrl(raw);
return normalizeMediaUrl(canonical || raw);
};
const normalizeImageTitle = (raw?: string) => {
const title = (raw || "").trim();
return title || "";
};
const getImageNodeTitle = (data: ImageNodeData, nodeId: string) => {
return (
normalizeImageTitle(data.title) ||
normalizeImageTitle(data.prompt) ||
`图片-${nodeId.slice(0, 6)}`
);
};
const sanitizeFileName = (name: string) => {
const safe = name
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
return safe || "image";
};
const GENERIC_CANVAS_PROMPTS = new Set([
"对话生成",
"图片生成",
"AI生成",
"智能生成",
"自动生成",
]);
const normalizePromptText = (value: unknown): string => {
return typeof value === "string" ? value.trim() : "";
};
const resolveRegeneratePromptSeed = (data: ImageNodeData): string => {
const metadataPrompt = normalizePromptText(data.metadata?.generationPrompt);
if (metadataPrompt) return metadataPrompt;
const nodePrompt = normalizePromptText(data.prompt);
if (nodePrompt && !GENERIC_CANVAS_PROMPTS.has(nodePrompt)) {
return nodePrompt;
}
return nodePrompt;
};
const COMMON_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
] as const;
function guessNearestAspectRatio(width: number, height: number): string {
if (!width || !height) return "auto";
const target = width / height;
let best = "auto";
let minDiff = Number.POSITIVE_INFINITY;
for (const ratio of COMMON_ASPECT_RATIOS) {
const [w, h] = ratio.split(":").map(Number);
const value = w / h;
const diff = Math.abs(target - value);
if (diff < minDiff) {
minDiff = diff;
best = ratio;
}
}
return best;
}
/**
* 在图片上绘制标记点,返回带标记的图片 base64
*/
async function drawMarkerOnImage(
imageUrl: string,
clickX: number,
clickY: number
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
const loadUrl = buildImageProxyUrl(imageUrl);
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 = loadUrl;
});
}
interface ImageNodeProps {
node: CanvasNode;
isSelected: boolean;
}
export function ImageNode({ node, isSelected }: ImageNodeProps) {
const data = node.data as ImageNodeData;
const detailProgressLabel =
typeof data.metadata?.detailProgressLabel === "string"
? data.metadata.detailProgressLabel.trim()
: "";
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false);
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
const [notePanelOpen, setNotePanelOpen] = useState(false);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const [textEditItems, setTextEditItems] = useState<ImageTextEditItem[]>(
() => normalizeTextEditItems(data.textEditItems),
);
const [textEditError, setTextEditError] = useState<string | null>(null);
const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [deletedTextItems, setDeletedTextItems] = useState<Array<{ id: string; originalText: string }>>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftNote, setDraftNote] = useState("");
const [isAddingToChat, setIsAddingToChat] = useState(false);
const [isCompareMode, setIsCompareMode] = useState(false);
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
const [boxCutoutToolbarSize, setBoxCutoutToolbarSize] = useState({ width: 0, height: 0 });
const [boxCutoutMode, setBoxCutoutMode] = useState(false);
const [isBoxCutoutDrawing, setIsBoxCutoutDrawing] = useState(false);
const [boxCutoutSelection, setBoxCutoutSelection] = useState<NormalizedCropRect | null>(null);
const [boxCutoutDragStart, setBoxCutoutDragStart] = useState<BoxCutoutPoint | null>(null);
const [boxCutoutDragEnd, setBoxCutoutDragEnd] = useState<BoxCutoutPoint | null>(null);
const [boxCutoutMetrics, setBoxCutoutMetrics] = useState<ImageDisplayMetrics | null>(null);
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const displayImageRef = useRef<HTMLImageElement>(null);
const compareFrameRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const boxCutoutToolbarRef = useRef<HTMLDivElement>(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 imageNodeActionRequest = useCanvasStore((state) => state.imageNodeActionRequest);
const clearImageNodeActionRequest = useCanvasStore((state) => state.clearImageNodeActionRequest);
const allNodes = useCanvasStore((state) => state.nodes);
// 🆕 物品拾取相关状态
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);
const displayTitle = getImageNodeTitle(data, node.id);
const agentExecutionSnapshotId =
typeof data.metadata?.agentExecutionSnapshotId === "string"
? data.metadata.agentExecutionSnapshotId.trim()
: "";
const detailUiScreenOverlay = useMemo(() => {
const raw = data.metadata?.detailUiScreenOverlay;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as DetailUiScreenOverlaySnapshot;
}, [data.metadata]);
const detailSubjectAnalysis = useMemo(() => {
return normalizeDetailUiSubjectAnalysis(data.metadata?.detailSubjectAnalysis);
}, [data.metadata]);
const detailUiDslDocumentFromMetadata = useMemo(() => {
const raw = data.metadata?.detailUiDslDocument;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as DetailUiDslV2Document;
}, [data.metadata]);
const detailUiDslDocument = useMemo(() => {
if (detailUiDslDocumentFromMetadata) return detailUiDslDocumentFromMetadata;
if (!detailUiScreenOverlay) return null;
return buildDetailUiDslV2({
overlay: detailUiScreenOverlay,
subjectAnalysis: detailSubjectAnalysis,
});
}, [detailSubjectAnalysis, detailUiDslDocumentFromMetadata, detailUiScreenOverlay]);
const shouldRenderDetailOverlay =
data.metadata?.detailGenerationMode === "agent_layout" && Boolean(detailUiScreenOverlay);
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
? agentExecutionSnapshotId.slice(0, 8)
: "";
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
const compareSource = useMemo(() => {
const targetIdentity = normalizeImageIdentityUrl(data.src || "");
if (!targetIdentity) return "";
const candidates: string[] = [];
const pushCandidate = (value: unknown) => {
const raw = String(value || "").trim();
if (!raw) return;
candidates.push(raw);
};
const visited = new Set<string>([node.id]);
const queue = Array.isArray(node.connections) ? [...node.connections] : [];
let safety = 0;
while (queue.length > 0 && safety < 120) {
safety += 1;
const sourceNodeId = queue.shift();
if (!sourceNodeId || visited.has(sourceNodeId)) continue;
visited.add(sourceNodeId);
const sourceNode = allNodes.find((item) => item.id === sourceNodeId);
if (!sourceNode || sourceNode.type !== "image") continue;
const sourceData = (sourceNode.data as any) || {};
pushCandidate(sourceData.src);
pushCandidate(sourceData.originalSrc);
pushCandidate(sourceData.metadata?.compareSourceUrl);
if (Array.isArray(sourceNode.connections) && sourceNode.connections.length > 0) {
queue.push(...sourceNode.connections);
}
}
pushCandidate(data.originalSrc);
pushCandidate(data.metadata?.compareSourceUrl);
for (const candidate of candidates) {
const identity = normalizeImageIdentityUrl(candidate);
if (!identity) continue;
if (identity !== targetIdentity) {
return candidate;
}
}
return "";
}, [allNodes, data.metadata, data.originalSrc, data.src, node.connections, node.id]);
const hasCompareSource = Boolean(data.src) && Boolean(compareSource);
const compareButtonVisualSize = useMemo(() => {
const minSide = Math.max(1, Math.min(node.size.width || 0, node.size.height || 0));
return Math.round(Math.max(24, Math.min(32, minSide * 0.09)));
}, [node.size.height, node.size.width]);
const compareButtonHitSize = useMemo(
() => Math.max(34, compareButtonVisualSize + 8),
[compareButtonVisualSize]
);
const compareButtonIconSize = useMemo(
() => Math.round(Math.max(12, Math.min(16, compareButtonVisualSize * 0.5))),
[compareButtonVisualSize]
);
const compareButtonInset = useMemo(
() => Math.max(6, Math.min(10, Math.round(compareButtonVisualSize * 0.22))),
[compareButtonVisualSize]
);
const displayImageWidth = useMemo(
() => Math.max(640, Math.min(2200, Math.round((node.size.width || 320) * 2))),
[node.size.width]
);
const displayImageSrc = useMemo(
() =>
buildImageProxyUrl(data.src, {
width: displayImageWidth,
quality: 86,
format: "webp",
}),
[data.src, displayImageWidth]
);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
width: displayImageWidth,
quality: 86,
format: "webp",
}),
[compareSource, displayImageWidth]
);
const [loadedResolution, setLoadedResolution] = useState<{ width: number; height: number } | null>(null);
const metadataResolution = useMemo(() => {
const rawWidth = Number((data.metadata as any)?.originalWidth ?? 0);
const rawHeight = Number((data.metadata as any)?.originalHeight ?? 0);
const width = Number.isFinite(rawWidth) ? Math.round(rawWidth) : 0;
const height = Number.isFinite(rawHeight) ? Math.round(rawHeight) : 0;
if (width > 0 && height > 0) {
return { width, height };
}
return null;
}, [data.metadata]);
const resolutionLabel = useMemo(() => {
const source = metadataResolution || loadedResolution;
if (!source) return "";
return `${source.width} x ${source.height}`;
}, [loadedResolution, metadataResolution]);
const boxCutoutDraftRect = useMemo(() => {
if (!boxCutoutMode || !boxCutoutDragStart || !boxCutoutDragEnd) {
return null;
}
return createNormalizedRect(boxCutoutDragStart, boxCutoutDragEnd);
}, [boxCutoutDragEnd, boxCutoutDragStart, boxCutoutMode]);
const activeBoxCutoutRect = boxCutoutSelection || boxCutoutDraftRect;
const activeBoxCutoutRectStyle = useMemo(() => {
if (!boxCutoutMetrics || !activeBoxCutoutRect) return null;
return {
left: boxCutoutMetrics.offsetX + activeBoxCutoutRect.x * boxCutoutMetrics.renderedWidth,
top: boxCutoutMetrics.offsetY + activeBoxCutoutRect.y * boxCutoutMetrics.renderedHeight,
width: activeBoxCutoutRect.width * boxCutoutMetrics.renderedWidth,
height: activeBoxCutoutRect.height * boxCutoutMetrics.renderedHeight,
};
}, [activeBoxCutoutRect, boxCutoutMetrics]);
const resetBoxCutoutState = useCallback(() => {
setBoxCutoutMode(false);
setIsBoxCutoutDrawing(false);
setBoxCutoutSelection(null);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
}, []);
const refreshBoxCutoutMetrics = useCallback(() => {
const imageElement = displayImageRef.current;
if (!imageElement) {
setBoxCutoutMetrics(null);
return;
}
const naturalWidth = imageElement.naturalWidth || metadataResolution?.width || loadedResolution?.width || 0;
const naturalHeight = imageElement.naturalHeight || metadataResolution?.height || loadedResolution?.height || 0;
const boxWidth = imageElement.clientWidth;
const boxHeight = imageElement.clientHeight;
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
setBoxCutoutMetrics(null);
return;
}
const displayScale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight);
const renderedWidth = naturalWidth * displayScale;
const renderedHeight = naturalHeight * displayScale;
setBoxCutoutMetrics({
offsetX: (boxWidth - renderedWidth) / 2,
offsetY: (boxHeight - renderedHeight) / 2,
renderedWidth,
renderedHeight,
naturalWidth,
naturalHeight,
});
}, [loadedResolution, metadataResolution]);
const getBoxCutoutPointerPoint = useCallback(
(clientX: number, clientY: number) => {
const imageElement = displayImageRef.current;
if (!imageElement) return null;
const currentMetrics = (() => {
if (
boxCutoutMetrics &&
boxCutoutMetrics.renderedWidth > 0 &&
boxCutoutMetrics.renderedHeight > 0
) {
return boxCutoutMetrics;
}
const naturalWidth = imageElement.naturalWidth || metadataResolution?.width || loadedResolution?.width || 0;
const naturalHeight = imageElement.naturalHeight || metadataResolution?.height || loadedResolution?.height || 0;
const boxWidth = imageElement.clientWidth;
const boxHeight = imageElement.clientHeight;
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
return null;
}
const displayScale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight);
return {
offsetX: (boxWidth - naturalWidth * displayScale) / 2,
offsetY: (boxHeight - naturalHeight * displayScale) / 2,
renderedWidth: naturalWidth * displayScale,
renderedHeight: naturalHeight * displayScale,
naturalWidth,
naturalHeight,
} satisfies ImageDisplayMetrics;
})();
if (!currentMetrics) return null;
const rect = imageElement.getBoundingClientRect();
const rawX = clientX - rect.left - currentMetrics.offsetX;
const rawY = clientY - rect.top - currentMetrics.offsetY;
const clampedX = clampNumber(rawX, 0, currentMetrics.renderedWidth);
const clampedY = clampNumber(rawY, 0, currentMetrics.renderedHeight);
return {
point: {
x: currentMetrics.renderedWidth > 0 ? clampedX / currentMetrics.renderedWidth : 0,
y: currentMetrics.renderedHeight > 0 ? clampedY / currentMetrics.renderedHeight : 0,
} satisfies BoxCutoutPoint,
insideImage:
rawX >= 0 &&
rawX <= currentMetrics.renderedWidth &&
rawY >= 0 &&
rawY <= currentMetrics.renderedHeight,
metrics: currentMetrics,
};
},
[boxCutoutMetrics, loadedResolution, metadataResolution]
);
// 退出拾取模式时清空状态
useEffect(() => {
if (!isThisNodePicking) {
setCurrentClick(null);
}
}, [isThisNodePicking]);
useEffect(() => {
if (!textEditPanelOpen) {
setTextEditItems(normalizeTextEditItems(data.textEditItems));
setDeletedTextItems([]);
setTextEditError(null);
}
}, [data.textEditItems, textEditPanelOpen]);
useEffect(() => {
if (!isSelected) {
setTextEditPanelOpen(false);
setIsEditingTitle(false);
setPromptDialogOpen(false);
resetBoxCutoutState();
}
}, [isSelected, resetBoxCutoutState]);
useEffect(() => {
if (!hasCompareSource || isThisNodePicking) {
setIsCompareMode(false);
}
}, [hasCompareSource, isThisNodePicking]);
useEffect(() => {
if ((isThisNodePicking || isCompareMode || sceneFusionDialogOpen || promptDialogOpen || notePanelOpen) && boxCutoutMode) {
resetBoxCutoutState();
}
}, [
boxCutoutMode,
isCompareMode,
isThisNodePicking,
notePanelOpen,
promptDialogOpen,
resetBoxCutoutState,
sceneFusionDialogOpen,
]);
useEffect(() => {
if (!boxCutoutMode || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
resetBoxCutoutState();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [boxCutoutMode, resetBoxCutoutState]);
useEffect(() => {
setLoadedResolution(null);
if (!isSelected || !data.src || metadataResolution) return;
let canceled = false;
const img = new Image();
img.onload = () => {
if (canceled) return;
const width = Math.round(img.naturalWidth || 0);
const height = Math.round(img.naturalHeight || 0);
if (width > 0 && height > 0) {
setLoadedResolution({ width, height });
}
};
img.onerror = () => {
if (!canceled) {
setLoadedResolution(null);
}
};
img.src = data.src;
return () => {
canceled = true;
};
}, [data.src, isSelected, metadataResolution]);
useEffect(() => {
if (typeof window === "undefined") return;
let rafId: number | null = null;
const scheduleRefresh = () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
};
scheduleRefresh();
window.addEventListener("resize", scheduleRefresh);
const resizeObserver =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleRefresh) : null;
if (containerRef.current) {
resizeObserver?.observe(containerRef.current);
}
if (displayImageRef.current) {
resizeObserver?.observe(displayImageRef.current);
}
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
window.removeEventListener("resize", scheduleRefresh);
resizeObserver?.disconnect();
};
}, [data.src, refreshBoxCutoutMetrics]);
const updateCompareSplitByClientX = useCallback((clientX: number) => {
const frame = compareFrameRef.current;
if (!frame) return;
const rect = frame.getBoundingClientRect();
if (rect.width <= 0) return;
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
setCompareSplitPercent(Math.max(0, Math.min(100, rawPercent)));
}, []);
useEffect(() => {
if (!isCompareMode) return;
const handlePointerDownOutside = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
const container = containerRef.current;
if (container && container.contains(target)) return;
setIsCompareMode(false);
};
document.addEventListener("pointerdown", handlePointerDownOutside, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDownOutside, true);
};
}, [isCompareMode]);
useEffect(() => {
if (!isEditingTitle) {
setDraftTitle(getImageNodeTitle(data, node.id));
}
}, [data, isEditingTitle, node.id]);
useEffect(() => {
if (promptDialogOpen) {
setRegeneratePrompt(resolveRegeneratePromptSeed(data));
}
}, [data, promptDialogOpen]);
useEffect(() => {
if (notePanelOpen) {
setDraftNote(data.note || "");
}
}, [data.note, notePanelOpen]);
useEffect(() => {
if (
typeof window === "undefined" ||
!isSelected ||
isMultiSelected ||
!data.src ||
isEditingTitle ||
sceneFusionDialogOpen ||
promptDialogOpen ||
notePanelOpen ||
boxCutoutMode
) {
return;
}
const toolbarEl = toolbarRef.current;
if (!toolbarEl) return;
let rafId: number | null = null;
const measure = () => {
const rect = toolbarEl.getBoundingClientRect();
const next = { width: rect.width, height: rect.height };
setToolbarSize((prev) =>
Math.abs(prev.width - next.width) < 0.5 && Math.abs(prev.height - next.height) < 0.5
? prev
: next
);
};
const scheduleMeasure = () => {
if (rafId !== null) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(measure);
};
scheduleMeasure();
window.addEventListener("resize", scheduleMeasure);
const resizeObserver =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleMeasure) : null;
resizeObserver?.observe(toolbarEl);
return () => {
window.removeEventListener("resize", scheduleMeasure);
resizeObserver?.disconnect();
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, [
boxCutoutMode,
data.src,
isEditingTitle,
isMultiSelected,
isSelected,
notePanelOpen,
promptDialogOpen,
sceneFusionDialogOpen,
]);
useEffect(() => {
if (
typeof window === "undefined" ||
!isSelected ||
isMultiSelected ||
!data.src ||
!boxCutoutMode
) {
return;
}
const toolbarEl = boxCutoutToolbarRef.current;
if (!toolbarEl) return;
let rafId: number | null = null;
const measure = () => {
const rect = toolbarEl.getBoundingClientRect();
const next = { width: rect.width, height: rect.height };
setBoxCutoutToolbarSize((prev) =>
Math.abs(prev.width - next.width) < 0.5 && Math.abs(prev.height - next.height) < 0.5
? prev
: next
);
};
const scheduleMeasure = () => {
if (rafId !== null) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(measure);
};
scheduleMeasure();
window.addEventListener("resize", scheduleMeasure);
const resizeObserver =
typeof ResizeObserver !== "undefined" ? new ResizeObserver(scheduleMeasure) : null;
resizeObserver?.observe(toolbarEl);
return () => {
window.removeEventListener("resize", scheduleMeasure);
resizeObserver?.disconnect();
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, [boxCutoutMode, data.src, isMultiSelected, isSelected]);
// 🆕 处理图片点击标注
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
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 handleEnterBoxCutoutMode = useCallback(
(event?: React.MouseEvent | React.PointerEvent) => {
event?.stopPropagation();
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
if (isThisNodePicking) {
setPickerMode(false);
}
setIsCompareMode(false);
setBoxCutoutSelection(null);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
setIsBoxCutoutDrawing(false);
setBoxCutoutMode(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
},
[data.src, isProcessing, isThisNodePicking, refreshBoxCutoutMetrics, setPickerMode]
);
const handleBoxCutoutPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode || isProcessing) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(event.clientX, event.clientY);
if (!pointerResult?.insideImage) {
return;
}
setBoxCutoutSelection(null);
setBoxCutoutDragStart(pointerResult.point);
setBoxCutoutDragEnd(pointerResult.point);
setIsBoxCutoutDrawing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[boxCutoutMode, getBoxCutoutPointerPoint, isProcessing]
);
const handleBoxCutoutPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode || !isBoxCutoutDrawing) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(event.clientX, event.clientY);
if (!pointerResult) return;
setBoxCutoutDragEnd(pointerResult.point);
},
[boxCutoutMode, getBoxCutoutPointerPoint, isBoxCutoutDrawing]
);
const finalizeBoxCutoutSelection = useCallback(
(clientX: number, clientY: number) => {
if (!boxCutoutMode || !boxCutoutDragStart) {
setIsBoxCutoutDrawing(false);
return;
}
const pointerResult = getBoxCutoutPointerPoint(clientX, clientY);
if (!pointerResult) {
setIsBoxCutoutDrawing(false);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
return;
}
const nextRect = createNormalizedRect(boxCutoutDragStart, pointerResult.point);
const nextWidthPx = nextRect.width * pointerResult.metrics.naturalWidth;
const nextHeightPx = nextRect.height * pointerResult.metrics.naturalHeight;
if (nextWidthPx >= BOX_CUTOUT_MIN_EDGE_PX && nextHeightPx >= BOX_CUTOUT_MIN_EDGE_PX) {
setBoxCutoutSelection(nextRect);
} else {
setBoxCutoutSelection(null);
}
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
setIsBoxCutoutDrawing(false);
},
[boxCutoutDragStart, boxCutoutMode, getBoxCutoutPointerPoint]
);
const handleBoxCutoutPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode) return;
event.preventDefault();
event.stopPropagation();
finalizeBoxCutoutSelection(event.clientX, event.clientY);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[boxCutoutMode, finalizeBoxCutoutSelection]
);
const handleBoxCutoutPointerCancel = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode) return;
event.preventDefault();
event.stopPropagation();
setIsBoxCutoutDrawing(false);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[boxCutoutMode]
);
const handleConfirmBoxCutout = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!boxCutoutSelection) {
toast.info("请先框选要抠出的元素");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessing(true);
const actionName = "框选抠图";
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}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
connections: [node.id],
});
try {
const result = await imageGenerationService.boxCutout({
imageUrl: data.src,
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
sourceNodeId: node.id,
});
if (result.success && result.data?.url) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
cropRectApplied: result.data.cropRectApplied,
paddedCropRectApplied: result.data.paddedCropRectApplied,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
editMode: "box-cutout",
},
},
});
toast.success(`${actionName}完成`);
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(result.error || `${actionName}失败`);
}
} catch (error) {
console.error("框选抠图失败:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(`${actionName}失败,请重试`);
} finally {
setIsProcessing(false);
resetBoxCutoutState();
}
}, [
addNode,
boxCutoutSelection,
data.src,
displayTitle,
isProcessing,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetBoxCutoutState,
updateNode,
]);
const animateAddToChat = useCallback(async () => {
if (!containerRef.current || !data.src || typeof window === "undefined") {
return false;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return false;
}
const target = document.querySelector('[data-canvas-chat-input-anchor="true"]') as HTMLElement | null;
if (!target) return false;
const sourceImage =
(containerRef.current.querySelector("img") as HTMLImageElement | null) || containerRef.current;
const sourceRect = sourceImage.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
if (sourceRect.width <= 0 || sourceRect.height <= 0 || targetRect.width <= 0 || targetRect.height <= 0) {
return false;
}
const waitForAnimation = (animation: Animation | null, fallbackMs: number) =>
new Promise<void>((resolve) => {
if (!animation) {
resolve();
return;
}
let finished = false;
const done = () => {
if (finished) return;
finished = true;
resolve();
};
const timeout = window.setTimeout(done, fallbackMs);
animation.addEventListener(
"finish",
() => {
window.clearTimeout(timeout);
done();
},
{ once: true }
);
animation.addEventListener(
"cancel",
() => {
window.clearTimeout(timeout);
done();
},
{ once: true }
);
});
const sourceCenterX = sourceRect.left + sourceRect.width / 2;
const sourceCenterY = sourceRect.top + sourceRect.height / 2;
const labelText = displayTitle.length > 12 ? `${displayTitle.slice(0, 10)}...` : displayTitle;
const tagWidth = Math.min(172, Math.max(112, 58 + labelText.length * 8));
const tagHeight = 34;
const tagLeft = Math.min(
Math.max(sourceCenterX - tagWidth / 2, 8),
window.innerWidth - tagWidth - 8
);
const defaultTagTop = sourceRect.top - 46;
const tagTop =
defaultTagTop < 10
? Math.min(sourceRect.bottom + 10, window.innerHeight - tagHeight - 10)
: defaultTagTop;
const flightTag = document.createElement("div");
flightTag.style.position = "fixed";
flightTag.style.left = `${sourceRect.left}px`;
flightTag.style.top = `${sourceRect.top}px`;
flightTag.style.width = `${sourceRect.width}px`;
flightTag.style.height = `${sourceRect.height}px`;
flightTag.style.borderRadius = "14px";
flightTag.style.overflow = "hidden";
flightTag.style.pointerEvents = "none";
flightTag.style.zIndex = "99999";
flightTag.style.background = "#ffffff";
flightTag.style.border = "1px solid rgba(148, 163, 184, 0.24)";
flightTag.style.boxShadow = "0 14px 30px rgba(15, 23, 42, 0.28)";
flightTag.style.transformOrigin = "center center";
flightTag.style.willChange = "transform, opacity, filter";
const previewImage = document.createElement("img");
previewImage.src = data.src;
previewImage.alt = "";
previewImage.style.width = "100%";
previewImage.style.height = "100%";
previewImage.style.objectFit = "cover";
flightTag.appendChild(previewImage);
document.body.appendChild(flightTag);
const compressDx = tagLeft + tagWidth / 2 - sourceCenterX;
const compressDy = tagTop + tagHeight / 2 - sourceCenterY;
const compressScaleX = tagWidth / sourceRect.width;
const compressScaleY = tagHeight / sourceRect.height;
const compressAnimation = flightTag.animate(
[
{
transform: "translate(0px, 0px) scale(1, 1)",
borderRadius: "14px",
boxShadow: "0 14px 30px rgba(15, 23, 42, 0.28)",
},
{
offset: 0.72,
transform: `translate(${compressDx * 1.03}px, ${compressDy * 1.03}px) scale(${compressScaleX * 1.05}, ${compressScaleY * 1.08})`,
borderRadius: "999px",
boxShadow: "0 10px 24px rgba(59, 130, 246, 0.24)",
},
{
transform: `translate(${compressDx}px, ${compressDy}px) scale(${compressScaleX}, ${compressScaleY})`,
borderRadius: "999px",
boxShadow: "0 8px 20px rgba(59, 130, 246, 0.2)",
},
],
{
duration: 320,
easing: "cubic-bezier(0.2, 0.9, 0.2, 1.2)",
fill: "forwards",
}
);
await waitForAnimation(compressAnimation, 420);
flightTag.style.left = `${tagLeft}px`;
flightTag.style.top = `${tagTop}px`;
flightTag.style.width = `${tagWidth}px`;
flightTag.style.height = `${tagHeight}px`;
flightTag.style.transform = "none";
flightTag.style.borderRadius = "999px";
flightTag.style.boxShadow = "0 8px 20px rgba(59, 130, 246, 0.2)";
flightTag.style.display = "flex";
flightTag.style.alignItems = "center";
flightTag.style.gap = "6px";
flightTag.style.padding = "4px 10px 4px 4px";
flightTag.style.background = "rgba(255,255,255,0.96)";
flightTag.style.backdropFilter = "blur(6px)";
flightTag.innerHTML = "";
const chipImage = document.createElement("img");
chipImage.src = data.src;
chipImage.alt = "";
chipImage.style.width = "24px";
chipImage.style.height = "24px";
chipImage.style.objectFit = "cover";
chipImage.style.borderRadius = "999px";
chipImage.style.border = "1px solid rgba(148, 163, 184, 0.35)";
const chipLabel = document.createElement("span");
chipLabel.textContent = labelText || "图片";
chipLabel.style.fontSize = "12px";
chipLabel.style.fontWeight = "500";
chipLabel.style.color = "rgb(51, 65, 85)";
chipLabel.style.whiteSpace = "nowrap";
chipLabel.style.overflow = "hidden";
chipLabel.style.textOverflow = "ellipsis";
flightTag.appendChild(chipImage);
flightTag.appendChild(chipLabel);
const tagCenterX = tagLeft + tagWidth / 2;
const tagCenterY = tagTop + tagHeight / 2;
const targetCenterX = targetRect.left + targetRect.width / 2;
const targetCenterY = targetRect.top + targetRect.height * 0.55;
const flyDx = targetCenterX - tagCenterX;
const flyDy = targetCenterY - tagCenterY;
const flyAnimation = flightTag.animate(
[
{ transform: "translate(0px, 0px) scale(1)", opacity: 1, filter: "blur(0px)" },
{
offset: 0.55,
transform: `translate(${flyDx * 0.64}px, ${flyDy * 0.62 - 46}px) scale(0.94)`,
opacity: 0.98,
filter: "blur(0px)",
},
{
offset: 0.84,
transform: `translate(${flyDx + 9}px, ${flyDy - 7}px) scale(0.74)`,
opacity: 0.82,
filter: "blur(0.4px)",
},
{
transform: `translate(${flyDx}px, ${flyDy}px) scale(0.64)`,
opacity: 0.15,
filter: "blur(1.2px)",
},
],
{
duration: 760,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "forwards",
}
);
target.animate(
[
{ boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" },
{ offset: 0.58, boxShadow: "0 0 0 8px rgba(59,130,246,0.14)", transform: "scale(1.02)" },
{ boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" },
],
{ duration: 720, easing: "cubic-bezier(0.2, 0.9, 0.2, 1.2)" }
);
await waitForAnimation(flyAnimation, 860);
flightTag.remove();
return true;
}, [data.src, displayTitle]);
const handleAddToChat = useCallback(async () => {
if (!data.src || isProcessing || isAddingToChat) return;
setIsAddingToChat(true);
try {
await animateAddToChat();
addPendingChatAttachment({
url: data.src,
name: displayTitle,
contentType: "image/jpeg",
});
toast.success("已添加到对话框");
} finally {
setIsAddingToChat(false);
}
}, [addPendingChatAttachment, animateAddToChat, data.src, displayTitle, isAddingToChat, isProcessing]);
const getFloatingViewportBounds = (toolbarHeight: number) => {
const viewportPadding = 12;
let unsafeBottomTop = window.innerHeight;
const bottomOverlaySelectors = ['[data-canvas-floating-toolbar="true"]'];
for (const selector of bottomOverlaySelectors) {
const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
for (const element of elements) {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") continue;
const overlayRect = element.getBoundingClientRect();
if (overlayRect.width <= 0 || overlayRect.height <= 0) continue;
if (overlayRect.bottom <= 0 || overlayRect.top >= window.innerHeight) continue;
if (overlayRect.top < window.innerHeight * 0.55) continue;
unsafeBottomTop = Math.min(unsafeBottomTop, overlayRect.top);
}
}
const safeBottom = Math.max(
viewportPadding,
Math.min(window.innerHeight - viewportPadding, unsafeBottomTop - viewportPadding)
);
const stageElement = document.querySelector('[data-canvas-stage="true"]') as HTMLElement | null;
const panelElement = document.querySelector('[data-canvas-right-panel="true"]') as HTMLElement | null;
let safeLeft = viewportPadding;
let safeRight = window.innerWidth - viewportPadding;
if (stageElement) {
const stageRect = stageElement.getBoundingClientRect();
if (stageRect.width > 0) {
safeLeft = Math.max(safeLeft, stageRect.left + viewportPadding);
safeRight = Math.min(safeRight, stageRect.right - viewportPadding);
}
}
if (panelElement) {
const panelRect = panelElement.getBoundingClientRect();
if (panelRect.width > 0 && panelRect.left < window.innerWidth) {
safeRight = Math.min(safeRight, panelRect.left - viewportPadding);
}
}
if (safeRight <= safeLeft + 8) {
safeLeft = viewportPadding;
safeRight = window.innerWidth - viewportPadding;
}
return {
viewportPadding,
safeBottom,
safeLeft,
safeRight,
maxTop: Math.max(viewportPadding, safeBottom - toolbarHeight),
};
};
// 获取工具栏位置 - 在渲染时直接计算
const getToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarScale = 0.8;
const toolbarWidth = Math.max(280, toolbarSize.width * toolbarScale || 980);
const toolbarHeight = Math.max(42, toolbarSize.height * toolbarScale || 48);
const titleSafeHeight = 34;
const gap = 12;
const { viewportPadding, maxTop, safeLeft, safeRight } = getFloatingViewportBounds(toolbarHeight);
let top = rect.top - toolbarHeight - titleSafeHeight - gap;
if (top < viewportPadding) {
top = rect.bottom + gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
const halfToolbar = effectiveToolbarWidth / 2;
let left = rect.left + rect.width / 2;
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = (safeLeft + safeRight) / 2;
} else {
left = Math.min(Math.max(left, safeLeft + halfToolbar), safeRight - halfToolbar);
}
return {
top,
left,
maxWidth: maxToolbarWidth,
};
}
return { top: 0, left: 0, maxWidth: 480 };
};
const getBoxCutoutToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarScale = 0.8;
const toolbarWidth = Math.max(240, boxCutoutToolbarSize.width * toolbarScale || 460);
const toolbarHeight = Math.max(42, boxCutoutToolbarSize.height * toolbarScale || 48);
const gap = 14;
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
getFloatingViewportBounds(toolbarHeight);
let top = rect.bottom + gap;
if (top + toolbarHeight > safeBottom) {
top = rect.top - toolbarHeight - gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
const halfToolbar = effectiveToolbarWidth / 2;
let left = rect.left + rect.width / 2;
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = (safeLeft + safeRight) / 2;
} else {
left = Math.min(Math.max(left, safeLeft + halfToolbar), safeRight - halfToolbar);
}
return {
top,
left,
maxWidth: maxToolbarWidth,
};
}
return { top: 0, left: 0, maxWidth: 460 };
};
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 handleSaveTitle = useCallback(() => {
const nextTitle = normalizeImageTitle(draftTitle) || displayTitle;
updateNode(node.id, {
data: {
...data,
title: nextTitle,
},
});
setDraftTitle(nextTitle);
setIsEditingTitle(false);
toast.success("图片名称已更新");
}, [data, displayTitle, draftTitle, node.id, updateNode]);
const handleOpenNotePanel = useCallback(() => {
setDraftNote(data.note || "");
setNotePanelOpen(true);
}, [data.note]);
const handleSaveNote = useCallback(() => {
const nextNote = draftNote.trim();
updateNode(node.id, {
data: {
...data,
note: nextNote || undefined,
},
});
setDraftNote(nextNote);
setNotePanelOpen(false);
toast.success(nextNote ? "备注已保存" : "备注已清除");
}, [data, draftNote, node.id, updateNode]);
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]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
clearImageNodeActionRequest();
if (imageNodeActionRequest.action === "box-cutout") {
handleEnterBoxCutoutMode();
return;
}
if (imageNodeActionRequest.action === "text-edit") {
void handleOpenTextEdit();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
]);
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: "文字替换处理中...",
title: `${displayTitle}-文字替换`,
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: "文字替换结果",
title: `${displayTitle}-文字替换`,
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: "文字替换失败",
title: `${displayTitle}-文字替换失败`,
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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 验证文件类型
if (!file.type.startsWith('image/')) {
toast.error('请上传图片文件');
return;
}
// 验证文件大小 (50MB)
if (file.size > 50 * 1024 * 1024) {
toast.error('图片大小不能超过 50MB');
return;
}
setIsUploading(true);
// 先显示上传中状态
updateNode(node.id, {
data: { ...data, status: 'generating' },
});
try {
const fileBaseName = file.name.replace(/\.[^.]+$/, "");
// 上传到存储桶
const result = await imageGenerationService.uploadImage(file);
if (result.success && result.data?.url) {
updateNode(node.id, {
data: {
...data,
src: result.data.url,
assetId: result.data.assetId,
status: 'completed',
title: normalizeImageTitle(data.title) || fileBaseName,
},
});
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<string, string> = {
'rmbg': '抠图',
'watermark': '去印',
'enhance': '增强',
'extend': '扩图',
'resize': '调尺',
'angle': '视角',
'scene-fusion': '批量处理',
'annotate': '标注',
'text-edit': '改字',
'note': '备注',
'prompt-regenerate': '重绘',
'box-cutout': '框选抠图',
};
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') {
setResizeAnnotatorOpen(true);
return;
}
if (action === "box-cutout") {
handleEnterBoxCutoutMode();
return;
}
if (action === 'annotate') {
setAnnotatorOpen(true);
return;
}
if (action === 'text-edit') {
await handleOpenTextEdit();
return;
}
if (action === "note") {
handleOpenNotePanel();
return;
}
if (action === "prompt-regenerate") {
setRegeneratePrompt(resolveRegeneratePromptSeed(data));
setPromptDialogOpen(true);
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}处理中...`,
title: `${displayTitle}-${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}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
});
}
toast.success(`${actionName}处理完成`);
} else {
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error("处理失败,请重试");
} finally {
setIsProcessing(false);
}
};
useEffect(() => {
if (typeof window === "undefined") return;
const onExternalAction = (event: Event) => {
const customEvent = event as CustomEvent<{ nodeId?: string; action?: string }>;
const targetNodeId = customEvent.detail?.nodeId;
const action = customEvent.detail?.action;
if (!targetNodeId || !action || targetNodeId !== node.id) return;
void handleAction(action);
};
window.addEventListener("canvas:image-action", onExternalAction as EventListener);
return () => {
window.removeEventListener("canvas:image-action", onExternalAction as EventListener);
};
}, [handleAction, node.id]);
// 角度编辑完成回调
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: '角度编辑结果',
title: `${displayTitle}-角度编辑`,
originalSrc: data.src,
},
connections: [node.id],
});
};
const handleSceneFusionBatchAddToCanvas = useCallback((imageUrls: string[]) => {
const validUrls = imageUrls.filter((url) => typeof url === "string" && url.trim().length > 0);
if (validUrls.length === 0) {
throw new Error("没有可添加到画布的图片");
}
const maxColumns = 3;
const gap = 20;
const baseX = node.position.x + node.size.width + gap;
const baseY = node.position.y;
validUrls.forEach((url, index) => {
const column = index % maxColumns;
const row = Math.floor(index / maxColumns);
addNode({
type: "image",
position: {
x: baseX + column * (node.size.width + gap),
y: baseY + row * (node.size.height + gap),
},
size: { width: node.size.width, height: node.size.height },
data: {
src: url,
status: "completed",
prompt: "批量处理结果",
title: `${displayTitle}-批量${index + 1}`,
originalSrc: data.src,
},
connections: [node.id],
});
});
}, [addNode, data.src, displayTitle, node.id, node.position.x, node.position.y, node.size.height, node.size.width]);
// 图片标注完成回调
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 fetchUploadWithRetry(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 uploadBase64ToStorage = useCallback(async (base64Url: string) => {
const response = await fetch(base64Url);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `canvas-resize-${Date.now()}.png`);
const uploadResponse = await fetchUploadWithRetry(formData);
if (!uploadResponse.ok) {
throw new Error("上传失败");
}
const result = await uploadResponse.json();
return result.url as string;
}, []);
const handleResizeEditorComplete = useCallback(
async (resizedImageUrl: string) => {
let finalUrl = resizedImageUrl;
if (resizedImageUrl.startsWith("data:")) {
try {
toast.info("正在上传尺寸调整结果...");
finalUrl = await uploadBase64ToStorage(resizedImageUrl);
} catch (error) {
console.error("上传尺寸调整图片失败:", error);
toast.error("尺寸调整结果上传失败");
return;
}
}
const title = `${displayTitle}-尺寸调整`;
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: finalUrl,
status: "completed",
prompt: "尺寸调整结果",
title,
originalSrc: data.src,
},
connections: [node.id],
});
setResizeAnnotatorOpen(false);
toast.success("尺寸调整结果已发送到画布");
},
[
addNode,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
uploadBase64ToStorage,
],
);
// 基于当前图片+新提示词重绘
const handlePromptRegenerate = async () => {
if (!data.src) return;
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
const promptText = regeneratePrompt.trim();
if (!promptText) {
toast.error("请输入提示词");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setPromptDialogOpen(false);
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: promptText,
title: `${displayTitle}-重绘`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const aspectRatio = guessNearestAspectRatio(node.size.width, node.size.height);
const result = await imageGenerationService.generateImage({
prompt: promptText,
referenceImage: data.src,
aspectRatio,
imageCount: 1,
});
const image = result.data?.images?.[0];
if (result.success && image?.originalUrl) {
updateNode(newNodeId, {
data: {
src: image.originalUrl,
status: "completed",
prompt: promptText,
title: `${promptText.slice(0, 24) || displayTitle}-重绘`,
originalSrc: data.src,
},
});
toast.success("已根据新提示词生成图片");
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "提示词重绘失败",
title: `${displayTitle}-重绘失败`,
originalSrc: data.src,
},
});
toast.error(result.error || "重绘失败");
}
} catch (error) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "提示词重绘失败",
title: `${displayTitle}-重绘失败`,
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 = `${sanitizeFileName(displayTitle)}.${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');
}
};
const toolbarPosition = getToolbarPosition();
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
const boxCutoutHintText = boxCutoutSelection
? "确认后将仅抠出框选区域"
: isBoxCutoutDrawing
? "松开完成框选"
: "在图片中拖拽矩形框";
return (
<>
{/* 选中时在图片上方显示标题 */}
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
<div className="absolute -top-7 left-0 right-0 z-10 flex items-center justify-between px-1 text-xs text-[#2f7df6]">
<div className="flex min-w-0 items-center gap-1">
<ImagePlus className="h-3 w-3" />
{isEditingTitle ? (
<Input
value={draftTitle}
autoFocus
data-canvas-no-drag="true"
className="h-6 px-2 py-0 text-xs w-[160px]"
onChange={(e) => setDraftTitle(e.target.value)}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onBlur={handleSaveTitle}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveTitle();
} else if (e.key === "Escape") {
e.preventDefault();
setDraftTitle(displayTitle);
setIsEditingTitle(false);
}
}}
/>
) : (
<span
className="max-w-[150px] cursor-text truncate"
title="双击重命名"
onDoubleClick={(e) => {
e.stopPropagation();
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
>
{displayTitle}
</span>
)}
</div>
{resolutionLabel ? (
<span className="ml-2 shrink-0 tabular-nums tracking-wide">{resolutionLabel}</span>
) : null}
</div>
)}
<div
ref={containerRef}
className={cn(
"relative w-full h-full bg-card rounded-[4px] overflow-hidden border shadow-sm transition-colors",
isSelected
? "rounded-none border-[2px] border-[#27a8ff] shadow-none"
: "border-transparent"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? (
<div className="relative w-full h-full">
{isCompareMode && hasCompareSource ? (
<div
ref={compareFrameRef}
className="relative h-full w-full overflow-hidden bg-muted/30 select-none cursor-col-resize"
style={{ touchAction: "none" }}
data-canvas-no-drag="true"
onPointerDown={(event) => {
event.stopPropagation();
updateCompareSplitByClientX(event.clientX);
}}
onPointerMove={(event) => {
updateCompareSplitByClientX(event.clientX);
}}
>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - compareSplitPercent}% 0 0)`,
}}
>
<div className="absolute inset-0 bg-muted/30" />
<img
src={displayImageSrc}
alt={displayTitle}
className="absolute inset-0 h-full w-full object-contain"
draggable={false}
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
/>
</div>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 0 0 ${compareSplitPercent}%)`,
}}
>
<div className="absolute inset-0 bg-muted/30" />
<img
src={displayCompareSrc}
alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-contain"
draggable={false}
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = compareSource;
}
}}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 z-30 w-[2px] bg-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.45),0_0_14px_rgba(255,255,255,0.55)]"
style={{ left: `${compareSplitPercent}%`, transform: "translateX(-50%)" }}
/>
<div
className="pointer-events-none absolute top-1/2 z-40 h-7 w-7 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/85 bg-black/70 text-white flex items-center justify-center"
style={{ left: `${compareSplitPercent}%` }}
aria-hidden="true"
>
&lt;&gt;
</div>
<div className="pointer-events-none absolute left-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white">
新图
</div>
<div className="pointer-events-none absolute right-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white">
原图
</div>
</div>
) : (
<>
<img
ref={displayImageRef}
src={displayImageSrc}
alt={displayTitle}
className={cn(
"w-full h-full object-contain bg-muted/30",
boxCutoutMode ? "cursor-crosshair" : isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
)}
draggable={false}
loading="lazy"
decoding="async"
onLoad={refreshBoxCutoutMetrics}
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
onClick={isThisNodePicking && !boxCutoutMode ? handlePickerClick : undefined}
onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode) {
e.stopPropagation();
setFullscreenPreviewOpen(true);
}
}}
/>
{shouldRenderDetailOverlay ? (
detailUiDslDocument ? (
<DetailUiDomRenderer
document={detailUiDslDocument}
imageMetrics={boxCutoutMetrics}
/>
) : (
<DetailSurfaceOverlayRenderer
overlay={detailUiScreenOverlay}
imageMetrics={boxCutoutMetrics}
subjectAnalysis={detailSubjectAnalysis}
/>
)
) : null}
</>
)}
{boxCutoutMode && !isThisNodePicking && !isCompareMode && (
<>
<div
className="absolute inset-0 z-[140] cursor-crosshair"
data-canvas-no-drag="true"
onPointerDown={handleBoxCutoutPointerDown}
onPointerMove={handleBoxCutoutPointerMove}
onPointerUp={handleBoxCutoutPointerUp}
onPointerCancel={handleBoxCutoutPointerCancel}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
/>
{activeBoxCutoutRectStyle ? (
<div
className="pointer-events-none absolute z-[145] border-2 border-[#27a8ff] bg-transparent shadow-[0_0_0_9999px_rgba(15,23,42,0.35)]"
style={{
left: activeBoxCutoutRectStyle.left,
top: activeBoxCutoutRectStyle.top,
width: Math.max(activeBoxCutoutRectStyle.width, 1),
height: Math.max(activeBoxCutoutRectStyle.height, 1),
}}
/>
) : (
<div className="pointer-events-none absolute inset-0 z-[141] bg-slate-950/18" />
)}
</>
)}
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
{isThisNodePicking && currentClick && (
<div
className="absolute pointer-events-none"
style={{
left: `${currentClick.x * 100}%`,
top: `${currentClick.y * 100}%`,
transform: 'translate(-50%, -50%)',
}}
>
<div className="w-6 h-6 rounded-full border-2 border-orange-500 border-dashed animate-spin" />
</div>
)}
{/* 🆕 拾取模式:状态栏 */}
{isThisNodePicking && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-white rounded-full px-3 py-1.5 shadow-lg">
<Crosshair className="h-4 w-4 text-orange-500" />
<span className="text-xs font-medium">
{isPickerLoading ? '正在识别...' : '点击标注物品'}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
handleCancelPicking();
}}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{hasCompareSource && !isThisNodePicking && !isCompareMode && !boxCutoutMode && isSelected && !isMultiSelected && (
<button
type="button"
className="pointer-events-auto absolute top-1/2 z-[220] inline-flex -translate-y-1/2 items-center justify-center rounded-full text-white shadow-[0_8px_16px_rgba(0,0,0,0.22)] transition-all"
data-canvas-no-drag="true"
style={{
width: `${compareButtonHitSize}px`,
height: `${compareButtonHitSize}px`,
right: `${compareButtonInset}px`,
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
setIsCompareMode((prev) => !prev);
}}
title={isCompareMode ? "对比已开启" : "开启对比"}
aria-label={isCompareMode ? "对比已开启" : "开启对比"}
>
<span
className="inline-flex items-center justify-center rounded-full border border-white/80 bg-black/78 text-white transition-colors hover:bg-black/88"
style={{
width: `${compareButtonVisualSize}px`,
height: `${compareButtonVisualSize}px`,
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ width: `${compareButtonIconSize}px`, height: `${compareButtonIconSize}px` }}
>
<rect x="3" y="4" width="18" height="16" rx="4.5" />
<path d="M12 4v16" />
<circle cx="12" cy="12" r="2.8" fill="currentColor" stroke="none" />
</svg>
</span>
</button>
)}
</div>
) : data.status === 'generating' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted select-none">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground text-center px-3 select-none">
{detailProgressLabel || "生成中..."}
</span>
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10 select-none">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm text-destructive select-none">生成失败</span>
</div>
) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted select-none">
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground mb-3 select-none">点击上传图片</span>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
{/* 选择文件按钮 */}
<button
type="button"
onClick={handleSelectFile}
className="px-4 py-2 bg-primary text-primary-foreground text-sm rounded-md hover:bg-primary/90 transition-colors cursor-pointer"
>
选择文件
</button>
</div>
)}
{/* 处理中遮罩 */}
{isProcessing && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="flex flex-col items-center">
<Loader2 className="h-8 w-8 animate-spin text-white mb-2" />
<span className="text-sm text-white">处理中...</span>
</div>
</div>
)}
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{/* 悬浮时的更多操作按钮 (右上角) */}
{isHovered && data.src && !isProcessing && !isSelected && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-7 w-7">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleAction('rmbg')}>
<Scissors className="h-4 w-4 mr-2" />
抠图
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('watermark')}>
<Eraser className="h-4 w-4 mr-2" />
去印
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('enhance')}>
<Sparkles className="h-4 w-4 mr-2" />
增强
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('note')}>
<StickyNote className="h-4 w-4 mr-2" />
备注
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('resize')}>
<Maximize2 className="h-4 w-4 mr-2" />
调尺
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('angle')}>
<Box className="h-4 w-4 mr-2" />
视角
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
<RefreshCw className="h-4 w-4 mr-2" />
重绘
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('text-edit')}>
<Type className="h-4 w-4 mr-2" />
改字
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('extend')}>
<Expand className="h-4 w-4 mr-2" />
扩图
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
>
<Pencil className="h-4 w-4 mr-2" />
改名
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* 提示词显示 */}
{data.prompt && isHovered && !isProcessing && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
</div>
)}
{data.note && !isSelected && (
<div
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-1 rounded-full bg-amber-100/95 px-2 py-1 text-[11px] text-amber-700 shadow-sm"
title="该图片含有备注"
>
<StickyNote className="h-3 w-3" />
备注
</div>
)}
{shortAgentExecutionSnapshotId ? (
<button
type="button"
onClick={() => setSnapshotDialogOpen(true)}
className={cn(
"absolute left-2 inline-flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-sm transition-colors hover:bg-black/80",
data.note && !isSelected ? "top-10" : "top-2"
)}
title={`执行快照:${agentExecutionSnapshotId}`}
>
快照 {shortAgentExecutionSnapshotId}
</button>
) : null}
</div>
{/* 角度编辑对话框 */}
{data.src && (
<AngleEditDialog
open={angleDialogOpen}
onOpenChange={setAngleDialogOpen}
imageUrl={data.src}
onComplete={handleAngleEditComplete}
/>
)}
<AgentExecutionSnapshotDialog
snapshotId={agentExecutionSnapshotId || null}
open={snapshotDialogOpen}
onOpenChange={setSnapshotDialogOpen}
/>
{/* 批量处理对话框 */}
<SceneFusionDialog
open={sceneFusionDialogOpen}
onOpenChange={setSceneFusionDialogOpen}
onBatchAddToCanvas={handleSceneFusionBatchAddToCanvas}
/>
{/* 提示词重绘对话框 */}
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
<DialogContent
overlayClassName="bg-black"
className="sm:max-w-[560px] border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none dark:border-slate-700 dark:bg-slate-950"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5" />
提示词重绘
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
</p>
<div className="space-y-2">
<Label htmlFor={`prompt-regenerate-${node.id}`}>新提示词</Label>
<Textarea
id={`prompt-regenerate-${node.id}`}
value={regeneratePrompt}
onChange={(e) => setRegeneratePrompt(e.target.value)}
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
rows={6}
className="resize-y border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPromptDialogOpen(false)}>取消</Button>
<Button onClick={handlePromptRegenerate} disabled={isProcessing || !regeneratePrompt.trim()}>
{isProcessing ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-2" />}
开始重绘
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{isSelected && !isMultiSelected && data.src && !isEditingTitle && !sceneFusionDialogOpen && !promptDialogOpen && !notePanelOpen && !boxCutoutMode && typeof document !== 'undefined' && createPortal(
<TooltipProvider delayDuration={200}>
<div
ref={toolbarRef}
className="fixed z-[9999] flex items-center rounded-[24px] border border-slate-200 bg-white/95 px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] backdrop-blur-md dark:border-slate-700 dark:bg-slate-900/95 dark:text-gray-100"
style={{
top: toolbarPosition.top,
left: toolbarPosition.left,
transform: 'translateX(-50%) scale(0.8)',
transformOrigin: 'top center',
pointerEvents: 'auto',
maxWidth: `${Math.max(180, Math.floor(toolbarPosition.maxWidth))}px`,
overflowX: 'auto',
scrollbarWidth: 'none',
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
disabled={isProcessing || isAddingToChat}
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 text-[13px] font-semibold text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
onClick={handleAddToChat}
>
{isAddingToChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquarePlus className="h-4 w-4" />
)}
<span className="whitespace-nowrap">添加对话</span>
</Button>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('enhance')}
>
<span className="mr-1.5 rounded-md border border-slate-300 px-1.5 text-[11px] leading-5 text-slate-600 dark:border-slate-500 dark:text-slate-100">HD</span>
高清增强
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('rmbg')}
>
<Scissors className="mr-1.5 h-4 w-4" />
抠图
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('watermark')}
>
<Eraser className="mr-1.5 h-4 w-4" />
去水印
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('resize')}
>
<Maximize2 className="mr-1.5 h-4 w-4" />
尺寸调整
</Button>
<Button
variant="ghost"
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('text-edit')}
>
<Type className="mr-1.5 h-4 w-4" />
文字替换
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('angle')}
>
<Box className="mr-1.5 h-4 w-4" />
视角生成
</Button>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-10 w-10 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
side="bottom"
className="bg-white opacity-100 backdrop-blur-none dark:bg-slate-900 dark:text-slate-100"
>
<DropdownMenuItem onClick={() => handleAction('extend')}>
<Expand className="mr-2 h-4 w-4" />
扩图
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
<RefreshCw className="mr-2 h-4 w-4" />
重绘
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('note')}>
<StickyNote className="mr-2 h-4 w-4" />
备注
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 focus:text-red-700"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-10 w-10 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={handleDownload}
>
<Download className="h-4 w-4" />
</Button>
</div>
</TooltipProvider>,
document.body
)}
{isSelected && !isMultiSelected && data.src && boxCutoutMode && typeof document !== "undefined" && createPortal(
<div
ref={boxCutoutToolbarRef}
className="fixed z-[9999] flex items-center rounded-[24px] border border-slate-200 bg-white/95 px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] backdrop-blur-md dark:border-slate-700 dark:bg-slate-900/95 dark:text-gray-100"
style={{
top: boxCutoutToolbarPosition.top,
left: boxCutoutToolbarPosition.left,
transform: "translateX(-50%) scale(0.8)",
transformOrigin: "top center",
pointerEvents: "auto",
maxWidth: `${Math.max(180, Math.floor(boxCutoutToolbarPosition.maxWidth))}px`,
overflowX: "auto",
scrollbarWidth: "none",
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xl bg-slate-100 px-3 text-[13px] font-medium text-slate-700 dark:bg-white/10 dark:text-slate-100">
<Scan className="h-4 w-4 text-[#27a8ff]" />
<span className="whitespace-nowrap">{boxCutoutHintText}</span>
</div>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<Button
variant="ghost"
disabled={isProcessing || !boxCutoutSelection}
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 text-[13px] font-semibold text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200 dark:disabled:bg-slate-700 dark:disabled:text-slate-400"
onClick={() => void handleConfirmBoxCutout()}
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
<span className="whitespace-nowrap">确认抠图</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => resetBoxCutoutState()}
>
<X className="h-4 w-4" />
</Button>
</div>,
document.body
)}
{/* 图片备注对话框 */}
<Dialog open={notePanelOpen} onOpenChange={setNotePanelOpen}>
<DialogContent
overlayClassName="bg-black"
className="sm:max-w-[560px] max-h-[85vh] overflow-y-auto border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none dark:border-slate-700 dark:bg-slate-950"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<StickyNote className="h-5 w-5" />
图片备注
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<Textarea
value={draftNote}
onChange={(e) => setDraftNote(e.target.value)}
placeholder="记录这张图的想法、待办或交付要求..."
rows={8}
className="resize-y min-h-[180px] border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
/>
<p className="text-xs text-muted-foreground">
备注仅在你点开时显示,不会直接覆盖在画面上。
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNotePanelOpen(false)}>
取消
</Button>
<Button onClick={handleSaveNote}>
保存备注
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 文字编辑面板(显示在图片节点旁边) */}
{isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] w-[360px] rounded-xl border border-border bg-background/90 backdrop-blur shadow-2xl"
style={{
top: getTextPanelPosition().top,
left: getTextPanelPosition().left,
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
<div className="flex items-center gap-2">
<Type className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">编辑文字</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setTextEditPanelOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="px-4 py-3 space-y-3 max-h-[360px] overflow-y-auto">
<p className="text-xs text-muted-foreground">
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
</p>
{isDetectingText && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
正在识别图片文字...
</div>
)}
{textEditError && !isDetectingText && (
<div className="rounded-md bg-destructive/10 text-destructive text-xs px-3 py-2">
{textEditError}
</div>
)}
{!isDetectingText && textEditItems.length === 0 && !textEditError && (
<div className="rounded-md bg-muted/50 text-muted-foreground text-xs px-3 py-3">
未识别到可编辑文字,可点击“重新识别”再试一次。
</div>
)}
{textEditItems.map((item, index) => (
<div key={item.id} className="rounded-lg border border-border/70 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] text-muted-foreground">
文本 {index + 1}
{typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveTextItem(item.id)}
title="删除该条文字"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
原文:{item.originalText}
</div>
<Input
value={item.editedText}
onChange={(e) => handleTextItemChange(item.id, e.target.value)}
placeholder="输入替换后的文字"
/>
</div>
))}
{deletedTextItems.length > 0 && (
<div className="rounded-md border border-dashed border-orange-300 bg-orange-50/60 px-3 py-2 text-xs text-orange-700">
已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-border/70">
<Button
variant="outline"
size="sm"
disabled={isDetectingText || isApplyingTextEdit}
onClick={() => detectTextItems(true)}
>
{isDetectingText ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
重新识别
</Button>
<Button
size="sm"
disabled={
isDetectingText ||
isApplyingTextEdit ||
(textEditItems.length === 0 && deletedTextItems.length === 0)
}
onClick={handleApplyTextEdit}
>
{isApplyingTextEdit ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}
确认替换
</Button>
</div>
</div>,
document.body
)}
{/* 图片标注器 */}
{annotatorOpen && data.src && (
<ImageAnnotator
imageUrl={data.src}
onConfirm={handleAnnotateComplete}
onCancel={() => setAnnotatorOpen(false)}
open={annotatorOpen}
onOpenChange={setAnnotatorOpen}
/>
)}
{resizeAnnotatorOpen && data.src && (
<ImageAnnotator
key={`resize-${node.id}-${data.src}`}
imageUrl={data.src}
initialResizeMode
confirmButtonText="发送到画布"
onConfirm={handleResizeEditorComplete}
onCancel={() => setResizeAnnotatorOpen(false)}
open={resizeAnnotatorOpen}
onOpenChange={setResizeAnnotatorOpen}
/>
)}
{/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen && data.src && createPortal(
<div
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center cursor-zoom-out"
onClick={() => setFullscreenPreviewOpen(false)}
>
{/* 关闭按钮 */}
<button
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={() => setFullscreenPreviewOpen(false)}
>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* 图片 */}
<img
src={data.src}
alt={data.prompt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
{/* 底部提示 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
点击任意位置关闭
</div>
</div>,
document.body
)}
</>
);
}