12130 lines
429 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 {
Box,
Camera,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Crosshair,
Crop,
Download,
Eraser,
Expand,
FileImage,
FlipHorizontal2,
FlipVertical2,
Globe2,
ImagePlus,
Layers,
Loader2,
Link2,
Maximize2,
MessageSquarePlus,
MoreHorizontal,
MoreVertical,
Pencil,
RefreshCw,
RotateCcw,
Scan,
Scissors,
SlidersHorizontal,
Sparkles,
StickyNote,
Trash2,
Type,
Unlink2,
Wand2,
X,
Zap,
} from "lucide-react";
import type React from "react";
import { useParams, useRouter } from "next/navigation";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import {
areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle,
DEFAULT_IMAGE_ADJUSTMENTS,
ImageAdjustFloatingPanel,
type ImageAdjustmentState,
type ImageAdjustPanelTab,
isDefaultImageAdjustments,
normalizeImageAdjustments,
} from "@/components/canvas/nodes/image-node/image-node-adjustments";
import { ImageNodeAdjustOverlays } from "@/components/canvas/nodes/image-node/image-node-adjust-overlays";
import {
ImageNodeDeferredLoadState,
ImageNodeEmptyState,
ImageNodeProcessingOverlay,
} from "@/components/canvas/nodes/image-node/image-node-empty-state";
import {
BoxCutoutPoint,
createCenteredCropRectForAspect,
createCenteredCropRectFromSize,
createNormalizedRect,
CROP_PRESET_GROUPS,
type CropInteractionHandle,
type CropPanelGroupKey,
DEFAULT_CROP_PRESET_ID,
getCropPresetById,
getNormalizedMinCropSize,
updateCropRectWithPointer,
} from "@/components/canvas/nodes/image-node/image-node-crop";
import {
getImageNodeTitle,
guessNearestAspectRatio,
inferModelFormat,
inferTripoImageType,
normalizeImageIdentityUrl,
normalizeImageTitle,
normalizeMediaUrl,
resolveRegeneratePromptSeed,
resolveOriginalImageUrl,
} from "@/components/canvas/nodes/image-node/image-node-utils";
import {
createLocalEditPromptDraftMap,
createLocalEditReferenceImageId,
createLocalEditRegionId,
getLocalEditRegionsFromMetadata,
getNormalizedRectOverlayStyle,
isHttpImageUrl,
LOCAL_EDIT_COLORS,
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
LOCAL_EDIT_MAX_REGIONS,
LOCAL_EDIT_MAX_POLL_ATTEMPTS,
LOCAL_EDIT_METADATA_KEY,
LOCAL_EDIT_MIN_EDGE_PX,
LOCAL_EDIT_POLL_INTERVAL_MS,
reindexLocalEditRegions,
type LocalEditReferenceImage,
type LocalEditRegion,
} from "@/components/canvas/nodes/image-node/image-node-local-edit";
import {
PromptRegenerateDialog,
ThreeDGenerationDialog,
} from "@/components/canvas/nodes/image-node/image-node-dialogs";
import { ImageNodeSelectedTitle } from "@/components/canvas/nodes/image-node/image-node-title";
import {
ImageAnnotator,
type ImageAnnotatorConfirmPayload,
} from "@/components/image-annotator";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import {
appendCanvasVisualAnnotationsMetadata,
syncCanvasLocalEditRegionAnnotationsMetadata,
syncCanvasNodeNoteAnnotationMetadata,
} from "@/lib/canvas/agent-annotation-metadata";
import {
dispatchCanvasAgentAnnotationEditRequest,
dispatchCanvasAgentOpenChatPanelRequest,
storeCanvasAgentAnnotationEditRequest,
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
import {
createCanvasPsdTemplateLaunchToken,
saveCanvasPsdTemplateLaunchPayload,
} from "@/lib/canvas/psd-template-launch";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
import {
type DetailUiSubjectAnalysis,
normalizeDetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
resolveThreeDModelBillingId,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
calculateEnhanceProPointsEstimate,
defaultPointsSystemConfig,
get3DTaskCreditCost,
get3DWorldCreditCost,
getImageToolCreditCost,
normalizePointsSystemConfig,
type ImageToolCreditFeature,
type PointsSystemConfig,
} from "@/lib/points/config";
import {
isArchivedWorldLabsAssetUrl,
pickBestArchivedWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils";
import {
getNodeContentScale,
getScaledNodeContentStyle,
} from "@/lib/canvas/node-content-scale";
import {
getSelectedImageInlineActionCount,
getCanvasToolbarItem,
isCanvasToolbarItemEnabled,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import {
AI_WASH_CONTROL_ORDER,
AI_WASH_DEFAULT_MODE_LABEL,
defaultAiWashConfig,
getEnabledAiWashPresets,
normalizeAiWashConfig,
type AiWashConfig,
type AiWashControl,
type AiWashControlId,
type AiWashControlLevel,
type AiWashPreset,
} from "@/lib/ai-wash/config";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { cn, fetcher } from "@/lib/utils";
import type {
CanvasTextEditPayloadItem,
NormalizedCropRect,
} from "@/services/canvas";
import {
canvasService,
imageGenerationService,
model3DGenerationService,
worldLabsGenerationService,
} from "@/services/canvas";
import type { PickedObject } from "@/store/canvas";
import { useCanvasStore } from "@/store/canvas";
import type {
CanvasNode,
ImageNodeData,
ImageTextEditItem,
} from "@/types/canvas";
// 已标注的物品标记(本地状态)
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 ImageOverlayRectStyle {
left: number;
top: number;
width: number;
height: number;
}
interface CanvasNodeAnnotationDisplayItem {
id: string;
type: "text-note" | "arrow-note" | "box" | "freehand" | "crop-box";
text?: string;
bounds?: {
x: number;
y: number;
width: number;
height: number;
};
targetPoint?: {
x: number;
y: number;
};
color?: string;
createdAt?: string;
createdByAgent?: boolean;
}
interface LayerSplitBounds {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
}
const AI_CLOUD_IMAGE_ACTIONS = new Set([
"ai-wash",
"rmbg",
"watermark",
"enhance",
"extend",
"layer-split",
"box-cutout",
"local-edit",
"scale-adjust",
"text-edit",
"prompt-regenerate",
"generate-3d-model",
"generate-3d-world",
]);
type AiWashResultStatus =
| "idle"
| "submitting"
| "running"
| "succeeded"
| "failed";
type AiWashModelOption = {
id: string;
modelId: string;
displayName: string;
avatarUrl?: string | null;
providerName?: string;
providerType?: string;
isDefault?: boolean;
unitCost: number;
creditDiscountLabel?: string | null;
availabilityStatus?: "available" | "maintenance" | "testing";
availabilityLabel?: string | null;
selectable?: boolean;
disabledReason?: string | null;
};
type AiWashConfigResponse = {
success?: boolean;
config?: AiWashConfig;
lockedModels?: AiWashModelOption[];
};
type AiWashModelOptionsResponse = {
success?: boolean;
defaultModelId?: string | null;
models?: AiWashModelOption[];
};
type MembershipSummaryResponse = {
isHighVip?: boolean;
pointsSystemConfig?: PointsSystemConfig;
};
type ImageActionCreditBadgeProps = {
label?: string | null;
compact?: boolean;
className?: string;
};
function formatPointAmount(value: number) {
const normalized = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return normalized.toLocaleString("en-US");
}
function formatImageNodeFeatureCostLabel({
cost,
isHighVip,
pointsEnabled,
}: {
cost: number;
isHighVip?: boolean;
pointsEnabled: boolean;
}) {
if (!pointsEnabled) return "不扣积分";
if (isHighVip) return "VIP免费";
if (cost <= 0) return "免费";
return `${formatPointAmount(cost)} 积分`;
}
function formatImageNodeFeatureCostShortLabel({
cost,
isHighVip,
pointsEnabled,
}: {
cost: number;
isHighVip?: boolean;
pointsEnabled: boolean;
}) {
if (!pointsEnabled) return null;
if (isHighVip) return "VIP";
if (cost <= 0) return "0";
return formatPointAmount(cost);
}
function ImageActionCreditBadge({
className,
compact = false,
label,
}: ImageActionCreditBadgeProps) {
if (!label) return null;
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-md bg-slate-900 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-white shadow-sm dark:bg-white dark:text-slate-950",
compact
? "pointer-events-none absolute -top-8 left-1/2 z-20 -translate-x-1/2 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100"
: "ml-auto",
className
)}
title={label}
>
<Zap className="h-3 w-3 fill-current" />
{label}
</span>
);
}
type AngleEditPanelMode = "subject" | "camera";
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
type CropInteractionState = {
handle: CropInteractionHandle;
pointerId: number;
startPoint: BoxCutoutPoint;
startRect: NormalizedCropRect;
};
type LocalEditRegionInteractionState = {
handle: CropInteractionHandle;
pointerId: number;
regionId: string;
target: "source" | "scaleTarget";
startPoint: BoxCutoutPoint;
startRect: NormalizedCropRect;
};
type CanvasLocalEditOperationMode = "local_edit" | "scale_adjust";
const CANVAS_SCALE_TARGET_MIN_PERCENT = -80;
const CANVAS_SCALE_TARGET_MAX_PERCENT = 200;
function getCanvasScaleAdjustRegionPrompt(
_scalePercent: number | null,
hasTargetPosition = false
) {
return [
"按目标框线对框选范围内的主物体执行等比比例调整。",
hasTargetPosition
? "红色框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出红色框,并由周边背景补全衔接。"
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
].join(" ");
}
function deriveCanvasScalePercentFromRects(
sourceRect: NormalizedCropRect,
targetRect: NormalizedCropRect | null | undefined
) {
if (!targetRect || sourceRect.width <= 0 || sourceRect.height <= 0) {
return 0;
}
const widthRatio = Math.max(0.05, targetRect.width / sourceRect.width);
const heightRatio = Math.max(0.05, targetRect.height / sourceRect.height);
const uniformRatio = Math.sqrt(widthRatio * heightRatio);
const percent = Math.round((uniformRatio - 1) * 100);
return Math.min(
CANVAS_SCALE_TARGET_MAX_PERCENT,
Math.max(CANVAS_SCALE_TARGET_MIN_PERCENT, percent)
);
}
type LocalEditPromptCompositionSnapshot = {
end: number;
start: number;
value: string;
};
type AnglePreviewDragState = {
pointerId: number;
startX: number;
startY: number;
startHorizontalAngle: number;
startVerticalAngle: number;
};
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
const BOX_CUTOUT_MIN_EDGE_PX = 24;
const isLocalEditControlTarget = (target: EventTarget | null) =>
target instanceof HTMLElement &&
Boolean(target.closest('[data-local-edit-control="true"]'));
const FLOATING_TOOLBAR_SCALE = 0.8;
const IMAGE_EDIT_PANEL_SCALE = 0.5;
const ANGLE_PANEL_SCALE = 0.65;
const ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER = 0.72;
const ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER = 0.46;
const ANGLE_PANEL_SOURCE_WIDTH = 520;
const ANGLE_PANEL_SOURCE_HEIGHT = 700;
const ANGLE_PANEL_PREVIEW_SIZE = 392;
const ANGLE_PANEL_CAMERA_SCENE_SIZE = 384;
const ANGLE_PANEL_WIREFRAME_RADIUS = 132;
const ANGLE_PANEL_WIREFRAME_DIAMETER = ANGLE_PANEL_WIREFRAME_RADIUS * 2;
const ANGLE_PREVIEW_VERTICAL_RING_ROTATIONS = [
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165,
];
const ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS = [
-78, -56, -34, -14, 0, 14, 34, 56, 78,
];
const ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58];
const normalizeAnglePanelHorizontalAngle = (value: number) => {
if (!Number.isFinite(value)) return 0;
return value < 0 ? 360 + value : value;
};
const mapAnglePanelZoomToApi = (value: number) => {
const safeValue = Math.max(
0,
Math.min(100, Number.isFinite(value) ? value : 50)
);
return Number((1 + (safeValue / 100) * 8).toFixed(1));
};
const formatAnglePanelZoomLabel = (value: number) => {
if (value <= 32) return "远景";
if (value >= 68) return "近景";
return "中等";
};
function AngleEditPanelSlider({
label,
max,
min,
onChange,
step = 1,
value,
valueLabel,
}: {
label: string;
max: number;
min: number;
onChange: (value: number) => void;
step?: number;
value: number;
valueLabel: string;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-4">
<span className="text-[17px] font-semibold tracking-[0] text-[#26272b]">
{label}
</span>
<span className="text-[16px] font-medium tabular-nums text-[#7d7f86]">
{valueLabel}
</span>
</div>
<Slider
className="w-full"
max={max}
min={min}
onValueChange={([nextValue]) => onChange(nextValue ?? value)}
rangeClassName="bg-[#2d3035]"
step={step}
thumbClassName="h-5 w-5 border-[4px] border-[#2d3035] bg-white shadow-[0_2px_10px_rgba(24,24,27,0.14)]"
trackClassName="h-[4px] bg-[#e7e8ec]"
value={[value]}
/>
</div>
);
}
function AngleCameraModelGlyph() {
return (
<div className="absolute left-1/2 top-1/2 h-[26px] w-[26px] -translate-x-1/2 -translate-y-1/2 [transform-style:preserve-3d]">
<div className="absolute left-[3px] top-[8px] h-[13px] w-[18px] rounded-[5px] border border-[#6a707a] bg-[linear-gradient(180deg,#f9fafc,#dce2ea)] shadow-[0_2px_6px_rgba(15,23,42,0.12)]" />
<div className="absolute left-[6px] top-[5px] h-[5px] w-[8px] rounded-[3px] border border-[#6f7680] bg-[linear-gradient(180deg,#ffffff,#e1e6ee)]" />
<div className="absolute left-[11px] top-[11px] h-[7px] w-[7px] rounded-full border border-[#5d636d] bg-[radial-gradient(circle_at_35%_35%,#fefefe_0%,#d7dde6_38%,#8c95a1_65%,#5c6470_100%)]" />
<div className="absolute left-[18px] top-[11px] h-[2px] w-[2px] rounded-full bg-[#69707b]" />
<div className="absolute left-[6px] top-[10px] h-[9px] w-[2px] rounded-full bg-[#c4ccd7]" />
</div>
);
}
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const fitSizeWithinBox = ({
maxHeight,
maxWidth,
minHeight = 72,
minWidth = 72,
sourceHeight,
sourceWidth,
}: {
maxHeight: number;
maxWidth: number;
minHeight?: number;
minWidth?: number;
sourceHeight: number;
sourceWidth: number;
}) => {
const safeSourceWidth = sourceWidth > 0 ? sourceWidth : maxWidth;
const safeSourceHeight = sourceHeight > 0 ? sourceHeight : maxHeight;
let scale = Math.min(maxWidth / safeSourceWidth, maxHeight / safeSourceHeight);
let fittedWidth = safeSourceWidth * scale;
let fittedHeight = safeSourceHeight * scale;
if (fittedWidth < minWidth) {
scale = minWidth / safeSourceWidth;
fittedWidth = safeSourceWidth * scale;
fittedHeight = safeSourceHeight * scale;
}
if (fittedHeight < minHeight) {
scale = minHeight / safeSourceHeight;
fittedWidth = safeSourceWidth * scale;
fittedHeight = safeSourceHeight * scale;
}
if (fittedWidth > maxWidth || fittedHeight > maxHeight) {
scale = Math.min(maxWidth / fittedWidth, maxHeight / fittedHeight);
fittedWidth *= scale;
fittedHeight *= scale;
}
return {
width: Math.round(fittedWidth),
height: Math.round(fittedHeight),
};
};
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(),
textColor: normalizeOptionalHexColor(item.textColor),
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
};
const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
const CANVAS_3D_POLL_INTERVAL_MS = 2000;
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
const AI_WASH_TASK_POLL_LIMIT = 80;
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
const HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
const HYDRATED_IMAGE_TASK_POLL_LIMIT = 240;
const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId(
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels
);
const DEFAULT_CANVAS_WORLD_MODEL = getDefaultThreeDModelAliasId(
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.worldModels
);
const sleep = (ms: number) =>
new Promise((resolve) => window.setTimeout(resolve, ms));
function getAiWashPresetControl(
preset: AiWashPreset | null | undefined,
controlId: AiWashControlId
): AiWashControl | null {
if (!preset) return null;
return preset.controls.find((control) => control.id === controlId) || null;
}
function getAiWashControlLevelByIndex(
control: AiWashControl | null,
selectedIndex: number
): AiWashControlLevel | null {
if (!control || control.levels.length === 0) return null;
const clampedIndex = Math.max(
0,
Math.min(control.levels.length - 1, Math.floor(selectedIndex))
);
return control.levels[clampedIndex] || control.levels[0] || null;
}
function composeAiWashQuickPrompt(params: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record<string, number>;
}) {
const promptParts = [params.preset.prompt.trim()].filter(Boolean);
for (const controlId of AI_WASH_CONTROL_ORDER) {
const level = getAiWashControlLevelByIndex(
getAiWashPresetControl(params.preset, controlId),
params.selectedLevelIndexByControl[controlId] ?? 0
);
const fragment = String(level?.promptFragment || "").trim();
if (fragment) {
promptParts.push(fragment);
}
}
return promptParts.join("\n");
}
function resolveAiWashQuickAdminModelId(params: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record<string, number>;
}) {
for (const controlId of AI_WASH_CONTROL_ORDER) {
const level = getAiWashControlLevelByIndex(
getAiWashPresetControl(params.preset, controlId),
params.selectedLevelIndexByControl[controlId] ?? 0
);
const levelModelId = String(level?.modelId || "").trim();
if (levelModelId) return levelModelId;
}
return String(params.preset.modelId || "").trim();
}
function getAiWashStatusLabel(status?: AiWashResultStatus) {
switch (status) {
case "submitting":
return "提交中";
case "running":
return "生成中";
case "succeeded":
return "已完成";
case "failed":
return "失败";
case "idle":
default:
return "待生成";
}
}
const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload";
function stripImageGenerationMetadata(metadata?: ImageNodeData["metadata"]) {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined;
}
const next = { ...metadata };
delete next.canvasGenerationPlaceholder;
delete next.detailProgressLabel;
delete next.generationProgress;
delete next.latestGeneratedHighlight;
delete next.recentlyGeneratedAt;
delete next.taskId;
delete next.taskRecoveredAt;
delete next.taskRecoveryStatus;
delete next.error;
return Object.keys(next).length > 0 ? next : undefined;
}
async function pollAiWashQuickTask(taskId: string) {
for (let attempt = 0; attempt < AI_WASH_TASK_POLL_LIMIT; attempt += 1) {
const response = await fetch(
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true || !payload?.task) {
throw new Error(payload?.error || "查询 AI洗图任务失败");
}
const task = payload.task as {
status: string;
error?: string | null;
result?: {
images?: Array<{ thumbnailUrl?: string; originalUrl?: string }>;
model?: string;
modelRef?: string;
prompt?: string;
} | null;
};
const status = String(task.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
if (!image?.originalUrl) {
throw new Error("AI洗图已完成,但没有拿到结果图片");
}
return {
imageUrl: image.originalUrl,
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
prompt: String(task.result?.prompt || "").trim(),
};
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "AI洗图任务失败");
}
await sleep(AI_WASH_TASK_POLL_INTERVAL_MS);
}
throw new Error("AI洗图任务轮询超时,请稍后重试");
}
const isTerminal3DFailureStatus = (status?: string | null) =>
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
function getChatAttachmentExtension(mimeType: string) {
const normalized = mimeType.toLowerCase();
if (normalized.includes("png")) return "png";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("gif")) return "gif";
if (normalized.includes("svg")) return "svg";
return "jpg";
}
function normalizeChatAttachmentFileName(name: string, mimeType: string) {
const baseName =
String(name || "canvas-image")
.trim()
.replace(/\.[^.]+$/, "")
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "canvas-image";
return `${baseName}-${Date.now()}.${getChatAttachmentExtension(mimeType)}`;
}
async function uploadChatAttachmentBlob(blob: Blob, displayName: string) {
const uploadBlob = blob.type ? blob : new Blob([blob], { type: "image/jpeg" });
if (!uploadBlob.type.startsWith("image/")) {
throw new Error("只能添加图片到对话");
}
if (!["image/jpeg", "image/jpg", "image/png", "image/webp"].includes(uploadBlob.type)) {
throw new Error("暂只支持 JPG、PNG、WebP 图片添加到对话");
}
if (uploadBlob.size > CHAT_ATTACHMENT_MAX_UPLOAD_BYTES) {
throw new Error("图片过大,请先压缩后再添加到对话");
}
const formData = new FormData();
const contentType = uploadBlob.type || "image/jpeg";
formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType));
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData);
let payload: any = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
throw new Error(payload?.error || "图片上传失败,请稍后重试");
}
const uploadedUrl = String(payload?.url || "").trim();
if (!isHttpImageUrl(uploadedUrl)) {
throw new Error("图片上传后未返回可用地址");
}
return {
url: uploadedUrl,
contentType,
};
}
async function resolveChatAttachmentFromImageSrc(src: string, displayName: string) {
const trimmedSrc = String(src || "").trim();
if (!trimmedSrc) {
throw new Error("当前图片地址不可用");
}
if (isHttpImageUrl(trimmedSrc)) {
return {
url: trimmedSrc,
contentType: "image/jpeg",
};
}
if (trimmedSrc.startsWith("data:image/")) {
if (trimmedSrc.length > CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH) {
throw new Error("图片数据过大,请先下载压缩后再添加到对话");
}
const blob = await fetch(trimmedSrc).then((response) => response.blob());
return uploadChatAttachmentBlob(blob, displayName);
}
if (trimmedSrc.startsWith("blob:")) {
const blob = await fetch(trimmedSrc).then((response) => response.blob());
return uploadChatAttachmentBlob(blob, displayName);
}
throw new Error("当前图片地址不可直接加入对话,请重新上传图片");
}
async function renderAndUploadFlippedImage(
imageSrc: string,
action: Exclude<FlipRotateEditAction, "reset">,
displayName: string
) {
const sourceUrl = buildImageProxyUrl(String(imageSrc || "").trim(), {
delivery: "proxy",
});
const response = await fetch(sourceUrl);
if (!response.ok) {
throw new Error("读取原图失败,请稍后重试");
}
const sourceBlob = await response.blob();
const objectUrl = URL.createObjectURL(sourceBlob);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("图片加载失败"));
img.src = objectUrl;
});
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) {
throw new Error("图片尺寸无效,无法执行翻转");
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("无法创建画布上下文");
}
ctx.save();
if (action === "horizontal") {
ctx.translate(width, 0);
ctx.scale(-1, 1);
} else {
ctx.translate(0, height);
ctx.scale(1, -1);
}
ctx.drawImage(image, 0, 0, width, height);
ctx.restore();
const outputType = ["image/png", "image/jpeg", "image/webp"].includes(
sourceBlob.type
)
? sourceBlob.type
: "image/png";
const transformedBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("导出翻转图片失败"));
},
outputType,
outputType === "image/jpeg" || outputType === "image/webp"
? 0.96
: undefined
);
});
const uploadResult = await uploadChatAttachmentBlob(
transformedBlob,
displayName
);
return uploadResult.url;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
async function renderAndUploadCroppedImage(
imageSrc: string,
cropRect: NormalizedCropRect,
displayName: string
) {
const sourceUrl = buildImageProxyUrl(String(imageSrc || "").trim(), {
delivery: "proxy",
});
const response = await fetch(sourceUrl);
if (!response.ok) {
throw new Error("读取原图失败,请稍后重试");
}
const sourceBlob = await response.blob();
const objectUrl = URL.createObjectURL(sourceBlob);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("图片加载失败"));
img.src = objectUrl;
});
const naturalWidth = image.naturalWidth || image.width;
const naturalHeight = image.naturalHeight || image.height;
if (!naturalWidth || !naturalHeight) {
throw new Error("图片尺寸无效,无法执行裁剪");
}
const left = Math.round(clampNumber(cropRect.x, 0, 1) * naturalWidth);
const top = Math.round(clampNumber(cropRect.y, 0, 1) * naturalHeight);
const width = Math.max(
BOX_CUTOUT_MIN_EDGE_PX,
Math.round(clampNumber(cropRect.width, 0, 1) * naturalWidth)
);
const height = Math.max(
BOX_CUTOUT_MIN_EDGE_PX,
Math.round(clampNumber(cropRect.height, 0, 1) * naturalHeight)
);
const safeWidth = Math.min(width, naturalWidth - left);
const safeHeight = Math.min(height, naturalHeight - top);
if (safeWidth <= 0 || safeHeight <= 0) {
throw new Error("裁剪区域无效,请重新调整");
}
const canvas = document.createElement("canvas");
canvas.width = safeWidth;
canvas.height = safeHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("无法创建画布上下文");
}
ctx.drawImage(
image,
left,
top,
safeWidth,
safeHeight,
0,
0,
safeWidth,
safeHeight
);
const outputType = ["image/png", "image/jpeg", "image/webp"].includes(
sourceBlob.type
)
? sourceBlob.type
: "image/png";
const croppedBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("导出裁剪图片失败"));
},
outputType,
outputType === "image/jpeg" || outputType === "image/webp"
? 0.96
: undefined
);
});
const uploadResult = await uploadChatAttachmentBlob(croppedBlob, displayName);
return {
url: uploadResult.url,
width: safeWidth,
height: safeHeight,
};
} finally {
URL.revokeObjectURL(objectUrl);
}
}
/**
* 在图片上绘制标记点,返回带标记的图片 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, { delivery: "proxy" });
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;
});
}
function normalizeCanvasNodeAnnotations(
metadata: unknown
): CanvasNodeAnnotationDisplayItem[] {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return [];
}
const rawAnnotations = (metadata as Record<string, unknown>).annotations;
if (!Array.isArray(rawAnnotations)) return [];
return rawAnnotations
.map((annotation, index): CanvasNodeAnnotationDisplayItem | null => {
if (!annotation || typeof annotation !== "object" || Array.isArray(annotation)) {
return null;
}
const raw = annotation as Record<string, unknown>;
const type = String(raw.type || "text-note");
if (
type !== "text-note" &&
type !== "arrow-note" &&
type !== "box" &&
type !== "freehand" &&
type !== "crop-box"
) {
return null;
}
const normalizePoint = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const point = value as Record<string, unknown>;
const x = Number(point.x);
const y = Number(point.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
};
const normalizeBounds = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const bounds = value as Record<string, unknown>;
const x = Number(bounds.x);
const y = Number(bounds.y);
const width = Number(bounds.width);
const height = Number(bounds.height);
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { x, y, width, height };
};
return {
id: String(raw.id || `annotation-${index + 1}`).slice(0, 128),
type: type as CanvasNodeAnnotationDisplayItem["type"],
text:
typeof raw.text === "string"
? String(raw.text).trim().slice(0, 180)
: undefined,
bounds: normalizeBounds(raw.bounds),
targetPoint: normalizePoint(raw.targetPoint),
color:
typeof raw.color === "string" ? String(raw.color).slice(0, 64) : undefined,
createdAt:
typeof raw.createdAt === "string"
? String(raw.createdAt).slice(0, 64)
: undefined,
createdByAgent: raw.createdByAgent === true,
};
})
.filter((annotation): annotation is CanvasNodeAnnotationDisplayItem =>
Boolean(annotation)
)
.slice(-12);
}
function getCanvasAnnotationBoundsStyle(
annotation: CanvasNodeAnnotationDisplayItem,
metrics: ImageDisplayMetrics | null
): React.CSSProperties | null {
if (!metrics || !annotation.bounds) return null;
const { bounds } = annotation;
const normalized =
Math.abs(bounds.x) <= 1 &&
Math.abs(bounds.y) <= 1 &&
Math.abs(bounds.width) <= 1 &&
Math.abs(bounds.height) <= 1;
if (normalized) {
return {
left: metrics.offsetX + bounds.x * metrics.renderedWidth,
top: metrics.offsetY + bounds.y * metrics.renderedHeight,
width: Math.max(bounds.width * metrics.renderedWidth, 1),
height: Math.max(bounds.height * metrics.renderedHeight, 1),
};
}
const scaleX =
metrics.naturalWidth > 0 ? metrics.renderedWidth / metrics.naturalWidth : 1;
const scaleY =
metrics.naturalHeight > 0 ? metrics.renderedHeight / metrics.naturalHeight : 1;
return {
left: metrics.offsetX + bounds.x * scaleX,
top: metrics.offsetY + bounds.y * scaleY,
width: Math.max(bounds.width * scaleX, 1),
height: Math.max(bounds.height * scaleY, 1),
};
}
interface ImageNodeProps {
node: CanvasNode;
isSelected: boolean;
isLayerSplitEnabled?: boolean;
batchProcessingEnabled?: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
angleEditEnabled?: boolean;
tripo3dEnabled?: boolean;
worldlabs3dEnabled?: boolean;
}
export function ImageNode({
node,
isSelected,
isLayerSplitEnabled = false,
batchProcessingEnabled = true,
canvasToolbarConfig,
angleEditEnabled = false,
tripo3dEnabled = false,
worldlabs3dEnabled = false,
}: ImageNodeProps) {
const router = useRouter();
const routeParams = useParams();
const canvasId = Array.isArray(routeParams?.id)
? routeParams.id[0] || ""
: typeof routeParams?.id === "string"
? routeParams.id
: "";
const data = node.data as ImageNodeData;
const { data: aiWashConfigPayload, isLoading: aiWashConfigLoading } =
useSWR<AiWashConfigResponse>("/api/ai-wash/config", fetcher);
const aiWashQuickConfig = useMemo(
() =>
normalizeAiWashConfig(
aiWashConfigPayload?.config || defaultAiWashConfig()
),
[aiWashConfigPayload?.config]
);
const aiWashQuickPresets = useMemo(
() => getEnabledAiWashPresets(aiWashQuickConfig).filter((preset) => preset.enabled),
[aiWashQuickConfig]
);
const imageAiWashQuickEnabled =
aiWashQuickConfig.enabled && aiWashQuickPresets.length > 0;
const aiWashQuickModeLabel =
aiWashQuickConfig.modeLabel || AI_WASH_DEFAULT_MODE_LABEL;
const {
data: aiWashModelOptionsPayload,
isLoading: aiWashModelOptionsLoading,
} = useSWR<AiWashModelOptionsResponse>(
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
fetcher
);
const { data: membershipSummary } = useSWR<MembershipSummaryResponse>(
"/api/user/membership-summary",
fetcher
);
const canvasAgentAnnotations = useMemo(
() => normalizeCanvasNodeAnnotations(data.metadata),
[data.metadata]
);
const visibleCanvasAgentAnnotations = useMemo(
() => canvasAgentAnnotations.slice(-3),
[canvasAgentAnnotations]
);
const hasCanvasAgentAnnotations = canvasAgentAnnotations.length > 0;
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
const imageEnhanceItem = getCanvasToolbarItem(canvasToolbarConfig, "imageEnhance");
const imageRemoveBackgroundItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveBackground"
);
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
const imagePsdTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
);
const imageRemoveWatermarkItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveWatermark"
);
const imageResizeItem = getCanvasToolbarItem(canvasToolbarConfig, "imageResize");
const imageCropItem = getCanvasToolbarItem(canvasToolbarConfig, "imageCrop");
const imageTextEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageTextEdit");
const imageMultiAngleItem = getCanvasToolbarItem(canvasToolbarConfig, "imageMultiAngle");
const imageGenerate3dModelItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageGenerate3dModel"
);
const imageGenerate3dWorldItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageGenerate3dWorld"
);
const imageLayerSplitItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLayerSplit");
const imageExtendItem = getCanvasToolbarItem(canvasToolbarConfig, "imageExtend");
const imageAdjustItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAdjust");
const imageFlipRotateItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageFlipRotate"
);
const imagePromptRegenerateItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePromptRegenerate"
);
const imageNoteItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNote");
const imageRenameItem = getCanvasToolbarItem(canvasToolbarConfig, "imageRename");
const imageDownloadItem = getCanvasToolbarItem(canvasToolbarConfig, "imageDownload");
const imageDeleteItem = getCanvasToolbarItem(canvasToolbarConfig, "imageDelete");
const imageCompareItem = getCanvasToolbarItem(canvasToolbarConfig, "imageCompare");
const imageConfirmCutoutItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageConfirmCutout"
);
const imageCancelCutoutItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageCancelCutout"
);
const imageNodeMoreEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageNodeMore"
);
const imageAddToChatEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageAddToChat"
);
const imagePickObjectEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imagePickObject"
);
const imageRemoveBackgroundEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageRemoveBackground"
);
const imageRemoveWatermarkEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageRemoveWatermark"
);
const imageEnhanceEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageEnhance"
);
const imageNoteEnabled = isCanvasToolbarItemEnabled(canvasToolbarConfig, "imageNote");
const imageBoxCutoutEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageBoxCutout"
);
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
// 也必须默认展示,避免历史配置把新功能吞掉。
const imageLocalEditEnabled = true;
// 模版替换的页面和接口已经存在;旧工具栏配置没有这个 key 时也要保留入口。
const imagePsdTemplateReplaceEnabled = true;
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageLayerSplit"
);
const imageResizeEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageResize"
);
const imageMultiAngleEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageMultiAngle"
) && angleEditEnabled;
const imageGenerate3dModelEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageGenerate3dModel"
) && tripo3dEnabled;
const imageGenerate3dWorldEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageGenerate3dWorld"
) && worldlabs3dEnabled;
const imagePromptRegenerateEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imagePromptRegenerate"
);
const imageTextEditEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageTextEdit"
);
const imageExtendEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageExtend"
);
const imageCropEnabled = isCanvasToolbarItemEnabled(canvasToolbarConfig, "imageCrop");
const imageAdjustEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageAdjust"
);
const imageFlipRotateEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageFlipRotate"
);
const imageRenameEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageRename"
);
const imageDownloadEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageDownload"
);
const imageDeleteEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageDelete"
);
const imageLayerSplitEnabled =
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
const selectedImageInlineActionCount =
getSelectedImageInlineActionCount(canvasToolbarConfig);
const selectedImageToolbarActionCandidates = [
{ key: "pickObject", enabled: imagePickObjectEnabled },
{ key: "enhance", enabled: imageEnhanceEnabled },
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
{ key: "aiWashQuick", enabled: imageAiWashQuickEnabled },
{ key: "textEdit", enabled: imageTextEditEnabled },
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
{ key: "localEdit", enabled: imageLocalEditEnabled },
{ key: "psdTemplate", enabled: imagePsdTemplateReplaceEnabled },
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
{ key: "resize", enabled: imageResizeEnabled },
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
{ key: "generate3dModel", enabled: imageGenerate3dModelEnabled },
{ key: "generate3dWorld", enabled: imageGenerate3dWorldEnabled },
{ key: "layerSplit", enabled: imageLayerSplitEnabled },
{ key: "extend", enabled: imageExtendEnabled },
{ key: "crop", enabled: imageCropEnabled },
{ key: "adjust", enabled: imageAdjustEnabled },
{ key: "flipRotate", enabled: imageFlipRotateEnabled },
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
{ key: "note", enabled: imageNoteEnabled },
] as const;
type SelectedImageToolbarActionKey =
(typeof selectedImageToolbarActionCandidates)[number]["key"];
const enabledSelectedImageToolbarActions =
selectedImageToolbarActionCandidates.filter((action) => action.enabled);
const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>(
enabledSelectedImageToolbarActions
.slice(0, selectedImageInlineActionCount)
.map((action) => action.key)
);
const selectedImageMoreActionKeys = new Set<SelectedImageToolbarActionKey>(
enabledSelectedImageToolbarActions
.slice(selectedImageInlineActionCount)
.map((action) => action.key)
);
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
selectedImageInlineActionKeys.has(key);
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
selectedImageMoreActionKeys.has(key);
const hasHoverImageMorePrimaryActions =
imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled ||
imageEnhanceEnabled ||
imagePsdTemplateReplaceEnabled ||
imageNoteEnabled ||
hasCanvasAgentAnnotations ||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
imageResizeEnabled ||
imageMultiAngleEnabled ||
imagePromptRegenerateEnabled ||
imageTextEditEnabled ||
imageExtendEnabled ||
imageCropEnabled ||
imageAdjustEnabled ||
imageFlipRotateEnabled ||
imageRenameEnabled;
const hasHoverImageMoreSecondaryActions =
imageDownloadEnabled || imageDeleteEnabled;
const hasHoverImageMoreMenu =
imageNodeMoreEnabled &&
(hasHoverImageMorePrimaryActions || hasHoverImageMoreSecondaryActions);
const hasSelectedImageMorePrimaryActions =
selectedImageMoreActionKeys.size > 0;
const hasSelectedImageMoreMenu =
hasSelectedImageMorePrimaryActions ||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
(imageNodeMoreEnabled && imageDeleteEnabled);
const hasSelectedImagePrimaryToolbarActions =
selectedImageInlineActionKeys.size > 0;
const hasSelectedImageSecondaryToolbarActions =
hasSelectedImageMoreMenu || imageDownloadEnabled;
const contentScale = useMemo(
() =>
getNodeContentScale(node.size, {
width: 220,
height: 220,
}),
[node.size.height, node.size.width]
);
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
const nodeContentScale = contentScale * viewportZoom;
const localEditPromptVisualScale = clampNumber(nodeContentScale, 0.5, 1);
const scaledContentStyle = useMemo(
() => getScaledNodeContentStyle(nodeContentScale, { allowUpscale: true }),
[nodeContentScale]
);
const selectedTitleStyle = useMemo<React.CSSProperties>(
() => ({
fontSize: 12,
lineHeight: "16px",
top: -28,
}),
[]
);
const detailProgressLabel =
typeof data.metadata?.detailProgressLabel === "string"
? data.metadata.detailProgressLabel.trim()
: "";
const generationProgress =
typeof data.metadata?.generationProgress === "number" &&
Number.isFinite(data.metadata.generationProgress)
? Math.max(0, Math.min(100, Math.round(data.metadata.generationProgress)))
: null;
const recentlyGeneratedAt =
typeof data.metadata?.recentlyGeneratedAt === "number" &&
Number.isFinite(data.metadata.recentlyGeneratedAt)
? data.metadata.recentlyGeneratedAt
: 0;
const hasPersistentGeneratedHighlight =
data.metadata?.latestGeneratedHighlight === true;
const hasTransientGeneratedHighlight =
data.metadata?.latestGeneratedHighlight !== false &&
Boolean(data.src) &&
recentlyGeneratedAt > 0 &&
Date.now() - recentlyGeneratedAt < 20_000;
const isRecentlyGenerated =
Boolean(data.src) &&
(hasPersistentGeneratedHighlight || hasTransientGeneratedHighlight);
const flipRotateOriginalSrc =
data.metadata && typeof data.metadata === "object"
? String((data.metadata as Record<string, unknown>).flipRotateOriginalSrc || "").trim()
: "";
const hasFlipRotateReset =
flipRotateOriginalSrc.length > 0 && flipRotateOriginalSrc !== data.src;
const storedImageAdjustments = useMemo(
() =>
normalizeImageAdjustments(
data.metadata && typeof data.metadata === "object"
? (data.metadata as Record<string, unknown>).imageAdjustments
: undefined
),
[data.metadata]
);
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const { data: modelAliasConfigResponse } = useSWR<{
success?: boolean;
data?: PublicThreeDModelAliasConfig;
}>("/api/canvas/model-aliases", fetcher);
const publicModelAliasConfig =
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
const modelFeatureVisibility =
publicModelAliasConfig.featureVisibility ||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.featureVisibility;
const tripoModelOptions = useMemo(() => {
return publicModelAliasConfig.tripoModels.filter(
(entry) => entry.enabled !== false
);
}, [publicModelAliasConfig.tripoModels]);
const worldModelOptions = useMemo(() => {
return publicModelAliasConfig.worldModels.filter(
(entry) => entry.enabled !== false
);
}, [publicModelAliasConfig.worldModels]);
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isCreatingDetachedResultNode, setIsCreatingDetachedResultNode] =
useState(false);
const [imageAdjustPanelOpen, setImageAdjustPanelOpen] = useState(false);
const [imageAdjustTab, setImageAdjustTab] =
useState<ImageAdjustPanelTab>("light");
const [imageAdjustmentsDraft, setImageAdjustmentsDraft] =
useState<ImageAdjustmentState>(storedImageAdjustments);
const [cropPanelOpen, setCropPanelOpen] = useState(false);
const [cropRect, setCropRect] = useState<NormalizedCropRect | null>(null);
const [cropPresetId, setCropPresetId] = useState(DEFAULT_CROP_PRESET_ID);
const [cropLockAspectRatio, setCropLockAspectRatio] = useState(true);
const [cropExpandedGroups, setCropExpandedGroups] = useState<
Record<CropPanelGroupKey, boolean>
>({
common: true,
instagram: false,
facebook: false,
tiktok: false,
youtube: false,
});
const [cropInteraction, setCropInteraction] =
useState<CropInteractionState | null>(null);
const [isApplyingCrop, setIsApplyingCrop] = useState(false);
const [flipRotatePanelOpen, setFlipRotatePanelOpen] = useState(false);
const [isApplyingFlipRotate, setIsApplyingFlipRotate] = useState(false);
const [anglePanelOpen, setAnglePanelOpen] = useState(false);
const [anglePanelMode, setAnglePanelMode] =
useState<AngleEditPanelMode>("camera");
const [angleHorizontalAngle, setAngleHorizontalAngle] = useState(-50);
const [angleVerticalAngle, setAngleVerticalAngle] = useState(-30);
const [angleZoom, setAngleZoom] = useState(50);
const [isApplyingAngleEdit, setIsApplyingAngleEdit] = useState(false);
const [isAnglePreviewDragging, setIsAnglePreviewDragging] = useState(false);
const [anglePanelOpening, setAnglePanelOpening] = useState(false);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false);
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
const [threedGenerationDialogMode, setThreedGenerationDialogMode] = useState<
"model" | "world" | null
>(null);
const [selectedTripoModelVersion, setSelectedTripoModelVersion] = useState(
DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard"
);
const [selectedWorldModelVersion, setSelectedWorldModelVersion] = useState(
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [psdTemplateDialogOpen, setPsdTemplateDialogOpen] = useState(false);
const [psdTemplateBrandName, setPsdTemplateBrandName] = useState("");
const { data: psdTemplateSummary, isLoading: isLoadingPsdTemplateSummary } =
useSWR<{
success?: boolean;
templates?: Array<{ brandName?: string | null }>;
}>(psdTemplateDialogOpen ? "/api/psd-templates?summary=1" : null, fetcher);
const availablePsdTemplateBrands = useMemo(
() =>
Array.from(
new Set(
(psdTemplateSummary?.templates || [])
.map((template) => String(template?.brandName || "").trim())
.filter(Boolean)
)
),
[psdTemplateSummary?.templates]
);
useEffect(() => {
if (
psdTemplateDialogOpen &&
!psdTemplateBrandName.trim() &&
availablePsdTemplateBrands.length === 1
) {
setPsdTemplateBrandName(availablePsdTemplateBrands[0] || "");
}
}, [
availablePsdTemplateBrands,
psdTemplateBrandName,
psdTemplateDialogOpen,
]);
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null);
const downloadAbortControllerRef = useRef<AbortController | null>(null);
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
const [notePanelOpen, setNotePanelOpen] = useState(false);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const [hasEnteredViewport, setHasEnteredViewport] = 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 [aiWashQuickPanelOpen, setAiWashQuickPanelOpen] = useState(false);
const [aiWashQuickPresetId, setAiWashQuickPresetId] = useState("");
const [aiWashQuickModelId, setAiWashQuickModelId] = useState("");
const [aiWashQuickLevels, setAiWashQuickLevels] = useState<
Record<string, number>
>({});
const [isSubmittingAiWashQuick, setIsSubmittingAiWashQuick] = useState(false);
const [aiWashQuickStatus, setAiWashQuickStatus] =
useState<AiWashResultStatus>("idle");
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 [localEditMode, setLocalEditMode] = useState(false);
const [localEditOperationMode, setLocalEditOperationMode] =
useState<CanvasLocalEditOperationMode>("local_edit");
const [localEditRegions, setLocalEditRegions] = useState<LocalEditRegion[]>(
() => getLocalEditRegionsFromMetadata(data.metadata)
);
const [localEditPromptDrafts, setLocalEditPromptDrafts] = useState<
Record<string, string>
>({});
const [isLocalEditDrawing, setIsLocalEditDrawing] = useState(false);
const [localEditDragStart, setLocalEditDragStart] =
useState<BoxCutoutPoint | null>(null);
const [localEditDragEnd, setLocalEditDragEnd] =
useState<BoxCutoutPoint | null>(null);
const [localEditDrawingTargetRegionId, setLocalEditDrawingTargetRegionId] =
useState<string | null>(null);
const [localEditRegionInteraction, setLocalEditRegionInteraction] =
useState<LocalEditRegionInteractionState | null>(null);
const [editingLocalEditRegionId, setEditingLocalEditRegionId] =
useState<string | null>(null);
const [isSubmittingLocalEdit, setIsSubmittingLocalEdit] = useState(false);
const [
uploadingLocalEditReferenceRegionId,
setUploadingLocalEditReferenceRegionId,
] = useState<string | null>(null);
const anglePreviewDragRef = useRef<AnglePreviewDragState | null>(null);
const activeImageAdjustments = imageAdjustPanelOpen
? imageAdjustmentsDraft
: storedImageAdjustments;
const activeImageAdjustmentsStyle = useMemo(
() => buildImageAdjustmentFilterStyle(activeImageAdjustments),
[activeImageAdjustments]
);
const hasActiveImageAdjustments = !isDefaultImageAdjustments(
activeImageAdjustments
);
const selectedAiWashQuickPreset = useMemo(
() =>
aiWashQuickPresets.find((preset) => preset.id === aiWashQuickPresetId) ||
aiWashQuickPresets.find((preset) => preset.isDefault) ||
aiWashQuickPresets[0] ||
null,
[aiWashQuickPresetId, aiWashQuickPresets]
);
const aiWashQuickControls = useMemo(
() =>
AI_WASH_CONTROL_ORDER.map((controlId) =>
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
).filter(Boolean) as AiWashControl[],
[selectedAiWashQuickPreset]
);
const aiWashQuickSelectableModels = useMemo(
() => aiWashModelOptionsPayload?.models || [],
[aiWashModelOptionsPayload?.models]
);
const aiWashQuickLockedModels = useMemo(
() => aiWashConfigPayload?.lockedModels || [],
[aiWashConfigPayload?.lockedModels]
);
const aiWashQuickModelOptionByRef = useMemo(() => {
const map = new Map<string, AiWashModelOption>();
[...aiWashQuickLockedModels, ...aiWashQuickSelectableModels].forEach((model) => {
map.set(model.id, model);
map.set(model.modelId, model);
});
return map;
}, [aiWashQuickLockedModels, aiWashQuickSelectableModels]);
const aiWashQuickAdminModelRef = useMemo(
() =>
selectedAiWashQuickPreset
? resolveAiWashQuickAdminModelId({
preset: selectedAiWashQuickPreset,
selectedLevelIndexByControl: aiWashQuickLevels,
})
: "",
[aiWashQuickLevels, selectedAiWashQuickPreset]
);
const shouldShowAiWashQuickModelSelector =
!aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef;
const effectiveAiWashQuickModelRef =
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
const selectedAiWashQuickModel = useMemo(
() =>
aiWashQuickModelOptionByRef.get(effectiveAiWashQuickModelRef) ||
aiWashQuickModelOptionByRef.get(aiWashQuickModelId) ||
null,
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
);
const aiWashQuickHasExecutableModel = Boolean(
effectiveAiWashQuickModelRef &&
selectedAiWashQuickModel &&
selectedAiWashQuickModel.selectable !== false
);
const aiWashQuickPrompt = useMemo(
() =>
selectedAiWashQuickPreset
? composeAiWashQuickPrompt({
preset: selectedAiWashQuickPreset,
selectedLevelIndexByControl: aiWashQuickLevels,
})
: "",
[aiWashQuickLevels, selectedAiWashQuickPreset]
);
useEffect(() => {
if (!batchProcessingEnabled) {
setSceneFusionDialogOpen(false);
}
}, [batchProcessingEnabled]);
useEffect(() => {
if (aiWashQuickPresets.length === 0) {
setAiWashQuickPresetId("");
return;
}
setAiWashQuickPresetId((current) => {
if (aiWashQuickPresets.some((preset) => preset.id === current)) {
return current;
}
return (
aiWashQuickPresets.find((preset) => preset.isDefault)?.id ||
aiWashQuickPresets[0]?.id ||
""
);
});
}, [aiWashQuickPresets]);
useEffect(() => {
if (!selectedAiWashQuickPreset) {
setAiWashQuickLevels({});
return;
}
setAiWashQuickLevels(
AI_WASH_CONTROL_ORDER.reduce<Record<string, number>>(
(accumulator, controlId) => {
accumulator[controlId] =
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
?.defaultLevel ?? 0;
return accumulator;
},
{}
)
);
setAiWashQuickStatus("idle");
}, [selectedAiWashQuickPreset]);
useEffect(() => {
const fallbackModelId =
String(aiWashModelOptionsPayload?.defaultModelId || "").trim() ||
aiWashQuickSelectableModels.find((model) => model.isDefault)?.id ||
aiWashQuickSelectableModels[0]?.id ||
"";
setAiWashQuickModelId((current) => {
if (
current &&
aiWashQuickSelectableModels.some((model) => model.id === current)
) {
return current;
}
return fallbackModelId;
});
}, [aiWashModelOptionsPayload?.defaultModelId, aiWashQuickSelectableModels]);
useEffect(() => {
if (!imageAiWashQuickEnabled || !isSelected) {
setAiWashQuickPanelOpen(false);
}
}, [imageAiWashQuickEnabled, isSelected]);
useEffect(() => {
if (!imageAdjustPanelOpen) return;
setImageAdjustmentsDraft(storedImageAdjustments);
}, [imageAdjustPanelOpen, storedImageAdjustments, data.src]);
useEffect(() => {
if (!cropPanelOpen) return;
if (cropRect) return;
const centerX = 0.5;
const centerY = 0.5;
const preset = getCropPresetById(cropPresetId);
if (preset) {
setCropRect(createCenteredCropRectForAspect(preset.ratio, centerX, centerY));
return;
}
setCropRect({
x: 0,
y: 0,
width: 1,
height: 1,
});
}, [cropPanelOpen, cropPresetId, cropRect]);
useEffect(() => {
if (!imageAdjustPanelOpen || typeof window === "undefined") return;
if (areImageAdjustmentsEqual(imageAdjustmentsDraft, storedImageAdjustments)) {
return;
}
const timer = window.setTimeout(() => {
const nextMetadata =
data.metadata && typeof data.metadata === "object"
? { ...(data.metadata as Record<string, unknown>) }
: {};
if (isDefaultImageAdjustments(imageAdjustmentsDraft)) {
delete nextMetadata.imageAdjustments;
delete nextMetadata.imageAdjustmentsUpdatedAt;
} else {
nextMetadata.imageAdjustments = imageAdjustmentsDraft;
nextMetadata.imageAdjustmentsUpdatedAt = new Date().toISOString();
}
useCanvasStore.getState().updateNode(node.id, {
data: {
...data,
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
},
});
}, 140);
return () => window.clearTimeout(timer);
}, [
data,
imageAdjustPanelOpen,
imageAdjustmentsDraft,
node.id,
storedImageAdjustments,
]);
useEffect(() => {
if (!anglePanelOpen) return;
const savedSettings =
data.metadata &&
typeof data.metadata === "object" &&
!Array.isArray(data.metadata)
? (data.metadata as {
angleEditSettings?: {
mode?: AngleEditPanelMode;
horizontalAngle?: number;
verticalAngle?: number;
zoom?: number;
};
}).angleEditSettings
: undefined;
if (!savedSettings) return;
if (savedSettings.mode === "subject" || savedSettings.mode === "camera") {
setAnglePanelMode(savedSettings.mode);
}
if (typeof savedSettings.horizontalAngle === "number") {
setAngleHorizontalAngle(clampNumber(savedSettings.horizontalAngle, -180, 180));
}
if (typeof savedSettings.verticalAngle === "number") {
setAngleVerticalAngle(clampNumber(savedSettings.verticalAngle, -30, 60));
}
if (typeof savedSettings.zoom === "number") {
setAngleZoom(clampNumber(savedSettings.zoom, 0, 100));
}
}, [anglePanelOpen, data.metadata]);
useEffect(() => {
if (!anglePanelOpen || typeof window === "undefined") {
setAnglePanelOpening(false);
return;
}
setAnglePanelOpening(true);
const timer = window.setTimeout(() => {
setAnglePanelOpening(false);
}, 560);
return () => window.clearTimeout(timer);
}, [anglePanelOpen]);
useEffect(() => {
const nextDefaultTripoModelId =
getDefaultThreeDModelAliasId(publicModelAliasConfig.tripoModels) ||
DEFAULT_CANVAS_TRIPO_MODEL ||
"tripo-standard";
if (!tripoModelOptions.some((entry) => entry.id === selectedTripoModelVersion)) {
setSelectedTripoModelVersion(nextDefaultTripoModelId);
}
}, [
publicModelAliasConfig.tripoModels,
selectedTripoModelVersion,
tripoModelOptions,
]);
useEffect(() => {
const nextDefaultWorldModelId =
getDefaultThreeDModelAliasId(publicModelAliasConfig.worldModels) ||
DEFAULT_CANVAS_WORLD_MODEL ||
"world-standard";
if (!worldModelOptions.some((entry) => entry.id === selectedWorldModelVersion)) {
setSelectedWorldModelVersion(nextDefaultWorldModelId);
}
}, [
publicModelAliasConfig.worldModels,
selectedWorldModelVersion,
worldModelOptions,
]);
const [boxCutoutMetrics, setBoxCutoutMetrics] =
useState<ImageDisplayMetrics | null>(null);
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [localEditPromptPortalFrame, setLocalEditPromptPortalFrame] = useState<{
left: number;
top: number;
width: number;
height: number;
} | null>(null);
const displayImageRef = useRef<HTMLImageElement>(null);
const compareFrameRef = useRef<HTMLDivElement>(null);
const cropOverlayRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const boxCutoutToolbarRef = useRef<HTMLDivElement>(null);
const localEditPromptInputRefs = useRef<
Record<string, HTMLInputElement | null>
>({});
const localEditPromptDraftRefs = useRef<Record<string, string>>({});
const localEditPromptCompositionStartRefs = useRef<
Record<string, LocalEditPromptCompositionSnapshot | undefined>
>({});
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
const hydratedImageTaskRecoveryRef = useRef<string | null>(null);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const addNode = useCanvasStore((state) => state.addNode);
const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
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);
useEffect(() => {
if (data.status !== "generating" || data.src) return;
const metadata =
data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata)
? (data.metadata as Record<string, unknown>)
: {};
const taskId = String(metadata.taskId || "").trim();
if (!taskId || hydratedImageTaskRecoveryRef.current === taskId) return;
const createdAtTime = new Date(node.createdAt).getTime();
if (
Number.isFinite(createdAtTime) &&
Date.now() - createdAtTime < HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS
) {
return;
}
let cancelled = false;
hydratedImageTaskRecoveryRef.current = taskId;
const updateCurrentGeneratingTaskNode = (
nextData: (currentData: ImageNodeData) => ImageNodeData
) => {
const currentNode = useCanvasStore
.getState()
.nodes.find((item) => item.id === node.id);
if (!currentNode || currentNode.type !== "image") return;
const currentData = currentNode.data as ImageNodeData;
const currentMetadata =
currentData.metadata &&
typeof currentData.metadata === "object" &&
!Array.isArray(currentData.metadata)
? (currentData.metadata as Record<string, unknown>)
: {};
if (
currentData.status !== "generating" ||
currentData.src ||
String(currentMetadata.taskId || "").trim() !== taskId
) {
return;
}
updateNode(node.id, {
data: nextData(currentData),
});
};
const markFailed = (message: string) => {
updateCurrentGeneratingTaskNode((currentData) => ({
...currentData,
src: "",
status: "failed",
prompt: message,
title: currentData.title?.includes("失败")
? currentData.title
: `${currentData.title || "图片生成"}-失败`,
metadata: {
...(currentData.metadata || {}),
error: message,
taskId,
taskRecoveredAt: new Date().toISOString(),
taskRecoveryStatus: "failed",
},
}));
};
void (async () => {
let lastError = "";
for (let attempt = 0; attempt < HYDRATED_IMAGE_TASK_POLL_LIMIT; attempt += 1) {
if (cancelled) return;
const taskResult = await imageGenerationService.getImageTask(taskId);
const task = taskResult.data?.task;
if (!taskResult.success || !task) {
lastError = taskResult.error || "查询生图任务失败";
} else {
const status = String(task.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
const imageUrl = image?.originalUrl || image?.thumbnailUrl || "";
if (!imageUrl) {
markFailed("任务已完成但未返回图片");
return;
}
updateCurrentGeneratingTaskNode((currentData) => ({
...currentData,
src: imageUrl,
status: "completed",
prompt: task.result?.prompt || currentData.prompt || "图片生成结果",
model: task.result?.model || task.result?.modelRef || currentData.model,
metadata: {
...(currentData.metadata || {}),
taskId,
taskRecoveredAt: new Date().toISOString(),
taskRecoveryStatus: "succeeded",
},
}));
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
markFailed(task.error || "图片生成任务失败");
return;
}
}
await sleep(HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS);
}
if (!cancelled) {
markFailed(lastError || "图片生成任务等待超时,请重新发起生成");
}
})();
return () => {
cancelled = true;
};
}, [data.metadata, data.src, data.status, node.createdAt, node.id, updateNode]);
const selectedTripoModelEntry = useMemo(
() =>
tripoModelOptions.find((entry) => entry.id === selectedTripoModelVersion) ||
tripoModelOptions[0] ||
null,
[selectedTripoModelVersion, tripoModelOptions]
);
const selectedWorldModelEntry = useMemo(
() =>
worldModelOptions.find((entry) => entry.id === selectedWorldModelVersion) ||
worldModelOptions[0] ||
null,
[selectedWorldModelVersion, worldModelOptions]
);
const pointsSystemConfig = useMemo(
() =>
normalizePointsSystemConfig(
membershipSummary?.pointsSystemConfig || defaultPointsSystemConfig
),
[membershipSummary?.pointsSystemConfig]
);
const pointsEnabled = pointsSystemConfig.enabled === true;
const isHighVip = membershipSummary?.isHighVip === true;
const tripoImageToModelCreditCost = useMemo(
() =>
get3DTaskCreditCost(pointsSystemConfig, {
taskType: "image_to_model",
}),
[pointsSystemConfig]
);
const worldImageCreditCost = useMemo(
() => {
const worldBillingModelId = resolveThreeDModelBillingId(
selectedWorldModelEntry?.id || selectedWorldModelVersion,
publicModelAliasConfig.worldModels
);
return get3DWorldCreditCost(pointsSystemConfig, worldBillingModelId);
},
[
pointsSystemConfig,
publicModelAliasConfig.worldModels,
selectedWorldModelEntry?.id,
selectedWorldModelVersion,
]
);
const tripoImageToModelCostLabel = formatImageNodeFeatureCostLabel({
cost: tripoImageToModelCreditCost,
isHighVip,
pointsEnabled,
});
const worldImageCostLabel = formatImageNodeFeatureCostLabel({
cost: worldImageCreditCost,
isHighVip,
pointsEnabled,
});
const tripoImageToModelCostShortLabel = formatImageNodeFeatureCostShortLabel({
cost: tripoImageToModelCreditCost,
isHighVip,
pointsEnabled,
});
const worldImageCostShortLabel = formatImageNodeFeatureCostShortLabel({
cost: worldImageCreditCost,
isHighVip,
pointsEnabled,
});
const getImageToolCostShortLabel = useCallback(
(feature: ImageToolCreditFeature) =>
formatImageNodeFeatureCostShortLabel({
cost: getImageToolCreditCost(pointsSystemConfig, feature),
isHighVip,
pointsEnabled,
}),
[isHighVip, pointsEnabled, pointsSystemConfig]
);
const removeBackgroundCostShortLabel =
getImageToolCostShortLabel("remove-background");
const boxCutoutCostShortLabel = getImageToolCostShortLabel("box-cutout");
const removeWatermarkCostShortLabel =
getImageToolCostShortLabel("watermark-remove");
const extendCostShortLabel = getImageToolCostShortLabel("extend");
const promptRegenerateCostShortLabel = getImageToolCostShortLabel("repaint");
const localEditCostShortLabel = promptRegenerateCostShortLabel;
const angleEditCostShortLabel = getImageToolCostShortLabel("angle-edit");
const enhanceCostShortLabel = formatImageNodeFeatureCostShortLabel({
cost: calculateEnhanceProPointsEstimate(pointsSystemConfig, {
width: Math.max(1, Math.round(node.size.width || 1)),
height: Math.max(1, Math.round(node.size.height || 1)),
upscalingActivated: true,
scaleFactor: 2,
}).cost,
isHighVip,
pointsEnabled:
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
});
const aiWashQuickCostShortLabel =
pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel
? formatImageNodeFeatureCostShortLabel({
cost: selectedAiWashQuickModel.unitCost,
isHighVip,
pointsEnabled,
})
: null;
// 🆕 物品拾取相关状态
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 compareSourceInfo = useMemo(() => {
const targetIdentity = normalizeImageIdentityUrl(data.src || "");
if (!targetIdentity) return { url: "", width: 0, height: 0 };
const candidates: Array<{ height: number; url: string; width: number }> = [];
const resolveCandidateResolution = (
sourceNode?: CanvasNode | null,
sourceData?: Partial<ImageNodeData> | null
) => {
const metadata = sourceData?.metadata || {};
const metadataWidth = Number((metadata as any).originalWidth || 0);
const metadataHeight = Number((metadata as any).originalHeight || 0);
const nodeWidth = Number(sourceNode?.size?.width || 0);
const nodeHeight = Number(sourceNode?.size?.height || 0);
const width = metadataWidth > 0 ? metadataWidth : nodeWidth;
const height = metadataHeight > 0 ? metadataHeight : nodeHeight;
return {
width: Number.isFinite(width) && width > 0 ? width : 0,
height: Number.isFinite(height) && height > 0 ? height : 0,
};
};
const pushCandidate = (
value: unknown,
sourceNode?: CanvasNode | null,
sourceData?: Partial<ImageNodeData> | null
) => {
const raw = String(value || "").trim();
if (!raw) return;
candidates.push({
url: raw,
...resolveCandidateResolution(sourceNode, sourceData),
});
};
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, sourceNode, sourceData);
pushCandidate(sourceData.originalSrc, sourceNode, sourceData);
pushCandidate(sourceData.metadata?.compareSourceUrl, sourceNode, sourceData);
if (
Array.isArray(sourceNode.connections) &&
sourceNode.connections.length > 0
) {
queue.push(...sourceNode.connections);
}
}
pushCandidate(data.originalSrc, node, data);
pushCandidate(data.metadata?.compareSourceUrl, node, data);
for (const candidate of candidates) {
const identity = normalizeImageIdentityUrl(candidate.url);
if (!identity) continue;
if (identity !== targetIdentity) {
return candidate;
}
}
return { url: "", width: 0, height: 0 };
}, [
allNodes,
data.metadata,
data.originalSrc,
data.src,
node.connections,
node.id,
node.size.height,
node.size.width,
]);
const compareSource = compareSourceInfo.url;
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 shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking;
const displayImageWidth = useMemo(() => {
const devicePixelRatio =
typeof window !== "undefined"
? clampNumber(window.devicePixelRatio || 1, 1, 3)
: 2;
const zoomMultiplier = clampNumber(
viewportZoom || 1,
0.75,
shouldUseHighFidelityDisplayImage ? 2.4 : 1.6
);
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
return Math.max(
minWidth,
Math.min(
maxWidth,
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
)
);
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSrc = useMemo(
() =>
buildImageProxyUrl(data.src, {
width: displayImageWidth,
quality: displayImageQuality,
format: "webp",
}),
[data.src, displayImageQuality, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
width: displayImageWidth,
quality: displayImageQuality,
format: "webp",
}),
[compareSource, displayImageQuality, displayImageWidth]
);
const shouldRequestDisplayImage =
hasEnteredViewport ||
isSelected ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
isThisNodePicking ||
fullscreenPreviewOpen ||
annotatorOpen ||
resizeAnnotatorOpen ||
textEditPanelOpen ||
imageAdjustPanelOpen ||
flipRotatePanelOpen ||
anglePanelOpen;
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 compareImageFrameStyle = useMemo<React.CSSProperties>(() => {
const boxWidth = Math.max(1, Number(node.size.width || 0));
const boxHeight = Math.max(1, Number(node.size.height || 0));
const activeResolution = metadataResolution || loadedResolution;
const naturalWidth = Math.max(1, Number(activeResolution?.width || boxWidth));
const naturalHeight = Math.max(1, Number(activeResolution?.height || boxHeight));
const scale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight);
const renderedWidth = Math.max(1, naturalWidth * scale);
const renderedHeight = Math.max(1, naturalHeight * scale);
return {
height: renderedHeight,
left: (boxWidth - renderedWidth) / 2,
top: (boxHeight - renderedHeight) / 2,
width: renderedWidth,
};
}, [loadedResolution, metadataResolution, node.size.height, node.size.width]);
const resolutionLabel = useMemo(() => {
const source = metadataResolution || loadedResolution;
if (!source) return "";
return `${source.width} x ${source.height}`;
}, [loadedResolution, metadataResolution]);
const angleSourceResolution = useMemo(() => {
const source = metadataResolution || loadedResolution;
if (source?.width && source?.height) {
return source;
}
return {
width: Math.max(1, Math.round(node.size.width || 1)),
height: Math.max(1, Math.round(node.size.height || 1)),
};
}, [loadedResolution, metadataResolution, node.size.height, node.size.width]);
const anglePreviewFrameSize = useMemo(
() =>
fitSizeWithinBox({
sourceWidth: angleSourceResolution.width,
sourceHeight: angleSourceResolution.height,
maxWidth: anglePanelMode === "subject" ? 236 : 188,
maxHeight: anglePanelMode === "subject" ? 196 : 150,
minWidth: 96,
minHeight: 80,
}),
[anglePanelMode, angleSourceResolution.height, angleSourceResolution.width]
);
const anglePreviewDepth = useMemo(
() =>
Math.round(
clampNumber(
anglePanelMode === "subject"
? anglePreviewFrameSize.width * 0.18
: anglePreviewFrameSize.width * 0.22,
16,
36
)
),
[anglePanelMode, anglePreviewFrameSize.width]
);
const anglePreviewScale = useMemo(
() =>
Number(
(
(anglePanelMode === "subject" ? 0.9 : 0.92) +
(angleZoom / 100) * (anglePanelMode === "subject" ? 0.46 : 0.34)
).toFixed(3)
),
[anglePanelMode, angleZoom]
);
const anglePreviewImageSrc = displayImageSrc || data.src;
const angleCameraOrbitPosition = useMemo(() => {
const horizontalRadians = (angleHorizontalAngle * Math.PI) / 180;
const normalizedVertical = (angleVerticalAngle + 30) / 90;
const x = Math.sin(horizontalRadians) * 110;
const y = -normalizedVertical * 72 + 26;
const z = Math.cos(horizontalRadians) * 38;
return {
x: Number(x.toFixed(2)),
y: Number(y.toFixed(2)),
z: Number(z.toFixed(2)),
};
}, [angleHorizontalAngle, angleVerticalAngle]);
const angleCameraConnection = useMemo(() => {
const length = Math.sqrt(
angleCameraOrbitPosition.x * angleCameraOrbitPosition.x +
angleCameraOrbitPosition.y * angleCameraOrbitPosition.y
);
const angle =
(Math.atan2(angleCameraOrbitPosition.y, angleCameraOrbitPosition.x) *
180) /
Math.PI;
return {
angle: Number(angle.toFixed(2)),
length: Number(length.toFixed(2)),
};
}, [angleCameraOrbitPosition]);
const angleOrbitWireframeStyle = useMemo<React.CSSProperties>(
() => ({
transform: `rotateX(${(-angleVerticalAngle * 0.24) + (anglePanelOpening ? 14 : 0)}deg) rotateY(${(angleHorizontalAngle * 0.34) + (anglePanelOpening ? -34 : 0)}deg) rotateZ(${(angleHorizontalAngle * 0.05) + (anglePanelOpening ? 7 : 0)}deg)`,
transformStyle: "preserve-3d",
transition: isAnglePreviewDragging
? "none"
: `transform ${anglePanelOpening ? 560 : 180}ms cubic-bezier(0.22, 1, 0.36, 1)`,
}),
[
angleHorizontalAngle,
anglePanelOpening,
angleVerticalAngle,
isAnglePreviewDragging,
]
);
const angleCameraSceneScale = useMemo(
() => Number((0.94 + (angleZoom / 100) * 0.22).toFixed(3)),
[angleZoom]
);
const cropImageMetrics = useMemo(() => {
if (boxCutoutMetrics) {
return {
naturalWidth: boxCutoutMetrics.naturalWidth,
naturalHeight: boxCutoutMetrics.naturalHeight,
};
}
const fallback = metadataResolution || loadedResolution;
if (!fallback) return null;
return {
naturalWidth: fallback.width,
naturalHeight: fallback.height,
};
}, [boxCutoutMetrics, 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 getNormalizedRectOverlayStyle(activeBoxCutoutRect, boxCutoutMetrics);
}, [activeBoxCutoutRect, boxCutoutMetrics]);
const localEditDraftRect = useMemo(() => {
if (!localEditMode || !localEditDragStart || !localEditDragEnd) {
return null;
}
return createNormalizedRect(localEditDragStart, localEditDragEnd);
}, [localEditDragEnd, localEditDragStart, localEditMode]);
const localEditDraftRectStyle = useMemo(() => {
if (!boxCutoutMetrics || !localEditDraftRect) return null;
return getNormalizedRectOverlayStyle(localEditDraftRect, boxCutoutMetrics);
}, [boxCutoutMetrics, localEditDraftRect]);
const localEditRegionOverlays = useMemo(() => {
if (!boxCutoutMetrics) return [];
return localEditRegions.map((region) => ({
region,
style: getNormalizedRectOverlayStyle(region.rect, boxCutoutMetrics),
}));
}, [boxCutoutMetrics, localEditRegions]);
const localEditScaleTargetOverlays = useMemo(() => {
if (!boxCutoutMetrics || localEditOperationMode !== "scale_adjust") {
return [];
}
return localEditRegions
.filter((region) => region.targetRect)
.map((region) => {
const targetRect = region.targetRect as NormalizedCropRect;
return {
region,
sourceStyle: getNormalizedRectOverlayStyle(region.rect, boxCutoutMetrics),
targetRect,
style: getNormalizedRectOverlayStyle(targetRect, boxCutoutMetrics),
};
});
}, [boxCutoutMetrics, localEditOperationMode, localEditRegions]);
const localEditPromptInlineOverlays = useMemo(() => {
if (!localEditMode || !boxCutoutMetrics) {
return [];
}
return localEditRegionOverlays.map(({ region, style }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const imageBottom = boxCutoutMetrics.offsetY + boxCutoutMetrics.renderedHeight;
const referenceCount = region.referenceImages?.length || 0;
const promptStackHeight = referenceCount > 0 ? 82 : 42;
const placeAbove = top + height + promptStackHeight + 14 > imageBottom;
const promptTop = placeAbove
? top - promptStackHeight - 8
: top + height + 8;
const promptWidth = Math.min(Math.max(width, 236), 360);
const isEditing =
editingLocalEditRegionId === region.id || !region.prompt.trim();
return {
region,
isEditing,
style: {
left,
top: Math.max(4, promptTop),
width: promptWidth,
zIndex: isEditing ? 190 : 170,
} satisfies React.CSSProperties,
};
});
}, [
boxCutoutMetrics,
editingLocalEditRegionId,
localEditMode,
localEditRegionOverlays,
]);
const localEditPromptViewportOverlays = useMemo(
() =>
localEditPromptInlineOverlays.map(({ region, isEditing, style }) => {
const viewportWidth = Number(style.width || 0) * nodeContentScale;
return {
region,
isEditing,
isAwaitingScaleTarget:
localEditOperationMode === "scale_adjust" && !region.targetRect,
style: {
...style,
left: Number(style.left || 0) * nodeContentScale,
top: Number(style.top || 0) * nodeContentScale,
transform: `scale(${localEditPromptVisualScale})`,
transformOrigin: "top left",
width:
localEditPromptVisualScale > 0
? viewportWidth / localEditPromptVisualScale
: viewportWidth,
} satisfies React.CSSProperties,
};
}),
[
localEditOperationMode,
localEditPromptInlineOverlays,
localEditPromptVisualScale,
nodeContentScale,
]
);
useEffect(() => {
if (
!localEditMode ||
!data.src ||
localEditPromptViewportOverlays.length === 0 ||
typeof window === "undefined"
) {
setLocalEditPromptPortalFrame(null);
return;
}
let animationFrame = 0;
const updatePortalFrame = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const nextFrame = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
setLocalEditPromptPortalFrame((currentFrame) => {
if (
currentFrame &&
Math.abs(currentFrame.left - nextFrame.left) < 0.5 &&
Math.abs(currentFrame.top - nextFrame.top) < 0.5 &&
Math.abs(currentFrame.width - nextFrame.width) < 0.5 &&
Math.abs(currentFrame.height - nextFrame.height) < 0.5
) {
return currentFrame;
}
return nextFrame;
});
};
const schedulePortalFrameUpdate = () => {
window.cancelAnimationFrame(animationFrame);
animationFrame = window.requestAnimationFrame(updatePortalFrame);
};
updatePortalFrame();
const intervalId = window.setInterval(updatePortalFrame, 250);
window.addEventListener("resize", schedulePortalFrameUpdate);
window.addEventListener("scroll", schedulePortalFrameUpdate, true);
return () => {
window.cancelAnimationFrame(animationFrame);
window.clearInterval(intervalId);
window.removeEventListener("resize", schedulePortalFrameUpdate);
window.removeEventListener("scroll", schedulePortalFrameUpdate, true);
};
}, [data.src, localEditMode, localEditPromptViewportOverlays.length]);
const currentPickerClickStyle = useMemo(() => {
if (!boxCutoutMetrics || !currentClick) return null;
return {
left:
boxCutoutMetrics.offsetX +
currentClick.x * boxCutoutMetrics.renderedWidth,
top:
boxCutoutMetrics.offsetY +
currentClick.y * boxCutoutMetrics.renderedHeight,
};
}, [boxCutoutMetrics, currentClick]);
const cropRectStyle = useMemo(() => {
if (!boxCutoutMetrics || !cropRect) return null;
return {
left: boxCutoutMetrics.offsetX + cropRect.x * boxCutoutMetrics.renderedWidth,
top: boxCutoutMetrics.offsetY + cropRect.y * boxCutoutMetrics.renderedHeight,
width: cropRect.width * boxCutoutMetrics.renderedWidth,
height: cropRect.height * boxCutoutMetrics.renderedHeight,
};
}, [boxCutoutMetrics, cropRect]);
const cropPixelSize = useMemo(() => {
if (!cropRect || !cropImageMetrics) {
return { width: 0, height: 0 };
}
return {
width: Math.max(
0,
Math.round(cropRect.width * cropImageMetrics.naturalWidth)
),
height: Math.max(
0,
Math.round(cropRect.height * cropImageMetrics.naturalHeight)
),
};
}, [cropImageMetrics, cropRect]);
const currentCropAspectRatio =
cropRect && cropRect.height > 0 ? cropRect.width / cropRect.height : null;
const selectedCropPreset = getCropPresetById(cropPresetId);
const effectiveCropAspectRatio =
cropLockAspectRatio
? selectedCropPreset?.ratio || currentCropAspectRatio
: null;
const resetBoxCutoutState = useCallback(() => {
setBoxCutoutMode(false);
setIsBoxCutoutDrawing(false);
setBoxCutoutSelection(null);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
}, []);
const resetLocalEditState = useCallback(() => {
setLocalEditMode(false);
setLocalEditOperationMode("local_edit");
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setLocalEditRegionInteraction(null);
setEditingLocalEditRegionId(null);
setUploadingLocalEditReferenceRegionId(null);
}, []);
const focusLocalEditPromptInput = useCallback((regionId: string) => {
if (typeof window === "undefined") return;
window.requestAnimationFrame(() => {
const input = localEditPromptInputRefs.current[regionId];
if (!input) return;
input.focus({ preventScroll: true });
const cursorPosition = input.value.length;
input.setSelectionRange(cursorPosition, cursorPosition);
});
}, []);
useEffect(() => {
if (!localEditMode || !editingLocalEditRegionId) return;
focusLocalEditPromptInput(editingLocalEditRegionId);
}, [editingLocalEditRegionId, focusLocalEditPromptInput, localEditMode]);
useEffect(() => {
localEditRegionsRef.current = localEditRegions;
}, [localEditRegions]);
const commitLocalEditRegions = useCallback(
(regions: LocalEditRegion[]) => {
const nextRegions = reindexLocalEditRegions(
regions.slice(0, LOCAL_EDIT_MAX_REGIONS)
);
const nextDrafts = createLocalEditPromptDraftMap(
nextRegions,
localEditPromptDraftRefs.current
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
localEditPromptDraftRefs.current = nextDrafts;
setLocalEditPromptDrafts(nextDrafts);
const nextMetadata =
data.metadata && typeof data.metadata === "object"
? { ...(data.metadata as Record<string, unknown>) }
: {};
if (nextRegions.length > 0) {
nextMetadata[LOCAL_EDIT_METADATA_KEY] = nextRegions;
} else {
delete nextMetadata[LOCAL_EDIT_METADATA_KEY];
}
const syncedAnnotationMetadata =
syncCanvasLocalEditRegionAnnotationsMetadata({
metadata: nextMetadata,
nodeId: node.id,
regions: nextRegions,
});
updateNode(node.id, {
data: {
...data,
metadata:
Object.keys(syncedAnnotationMetadata.metadata).length > 0
? syncedAnnotationMetadata.metadata
: undefined,
},
});
},
[data, node.id, updateNode]
);
const updateLocalEditPromptDraft = useCallback(
(regionId: string, prompt: string) => {
localEditPromptDraftRefs.current[regionId] = prompt;
setLocalEditPromptDrafts((current) =>
current[regionId] === prompt
? current
: {
...current,
[regionId]: prompt,
}
);
},
[]
);
const updateLocalEditPromptDraftRef = useCallback(
(regionId: string, prompt: string) => {
localEditPromptDraftRefs.current[regionId] = prompt;
},
[]
);
const restoreLocalEditPromptSelection = useCallback(
(regionId: string, position: number) => {
if (typeof window === "undefined") return;
window.requestAnimationFrame(() => {
const input = localEditPromptInputRefs.current[regionId];
if (!input) return;
const safePosition = clampNumber(position, 0, input.value.length);
input.setSelectionRange(safePosition, safePosition);
});
},
[]
);
const applyLocalEditPromptKeyEdit = useCallback(
(
regionId: string,
input: HTMLInputElement,
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (
event.nativeEvent.isComposing ||
event.key === "Process" ||
event.metaKey ||
event.ctrlKey ||
event.altKey
) {
return false;
}
const currentValue = localEditPromptDraftRefs.current[regionId] ?? input.value;
const selectionStart = input.selectionStart ?? currentValue.length;
const selectionEnd = input.selectionEnd ?? selectionStart;
const start = clampNumber(selectionStart, 0, currentValue.length);
const end = clampNumber(selectionEnd, start, currentValue.length);
let nextValue: string | null = null;
let nextCursor = start;
if (event.key.length === 1) {
nextValue = `${currentValue.slice(0, start)}${event.key}${currentValue.slice(end)}`;
nextCursor = start + event.key.length;
} else if (event.key === "Backspace") {
if (start !== end) {
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(end)}`;
nextCursor = start;
} else if (start > 0) {
nextValue = `${currentValue.slice(0, start - 1)}${currentValue.slice(end)}`;
nextCursor = start - 1;
}
} else if (event.key === "Delete") {
if (start !== end) {
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(end)}`;
} else if (start < currentValue.length) {
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(start + 1)}`;
}
nextCursor = start;
}
if (nextValue === null) {
return false;
}
event.preventDefault();
input.value = nextValue;
updateLocalEditPromptDraftRef(regionId, nextValue);
restoreLocalEditPromptSelection(regionId, nextCursor);
return true;
},
[restoreLocalEditPromptSelection, updateLocalEditPromptDraftRef]
);
const finishLocalEditPromptComposition = useCallback(
(regionId: string, input: HTMLInputElement, compositionText: string) => {
const snapshot = localEditPromptCompositionStartRefs.current[regionId];
delete localEditPromptCompositionStartRefs.current[regionId];
if (!snapshot) {
updateLocalEditPromptDraftRef(regionId, input.value);
return;
}
if (input.value !== snapshot.value) {
updateLocalEditPromptDraftRef(regionId, input.value);
return;
}
if (!compositionText) {
return;
}
const nextValue = `${snapshot.value.slice(0, snapshot.start)}${compositionText}${snapshot.value.slice(snapshot.end)}`;
input.value = nextValue;
updateLocalEditPromptDraftRef(regionId, nextValue);
restoreLocalEditPromptSelection(
regionId,
snapshot.start + compositionText.length
);
},
[restoreLocalEditPromptSelection, updateLocalEditPromptDraftRef]
);
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 handleDisplayImageLoad = useCallback(
(event: React.SyntheticEvent<HTMLImageElement>) => {
const width = Math.round(event.currentTarget.naturalWidth || 0);
const height = Math.round(event.currentTarget.naturalHeight || 0);
if (width > 0 && height > 0) {
setLoadedResolution((current) => {
if (current?.width === width && current?.height === height) {
return current;
}
return { width, height };
});
}
refreshBoxCutoutMetrics();
},
[refreshBoxCutoutMetrics]
);
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();
if (rect.width <= 0 || rect.height <= 0) return null;
// The image node content may be counter-scaled with CSS transforms.
// getBoundingClientRect() returns the transformed size, while
// object-contain offsets are calculated in the element's local box.
// Convert the pointer back into that local coordinate space first.
const localX =
((clientX - rect.left) / rect.width) * imageElement.clientWidth;
const localY =
((clientY - rect.top) / rect.height) * imageElement.clientHeight;
const rawX = localX - currentMetrics.offsetX;
const rawY = localY - 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 (localEditMode) return;
const nextRegions = getLocalEditRegionsFromMetadata(data.metadata);
const nextDrafts = createLocalEditPromptDraftMap(nextRegions);
localEditPromptDraftRefs.current = nextDrafts;
setLocalEditPromptDrafts(nextDrafts);
setLocalEditRegions(nextRegions);
}, [data.metadata, data.src, localEditMode]);
useEffect(() => {
if (!isSelected) {
setCropPanelOpen(false);
setCropRect(null);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setIsEditingTitle(false);
setPromptDialogOpen(false);
resetBoxCutoutState();
// 局部编辑有独立的“退出”按钮,避免用户点到浮层或画布边缘时丢失框选与底部操作条。
if (!localEditMode) {
resetLocalEditState();
}
}
}, [isSelected, localEditMode, resetBoxCutoutState, resetLocalEditState]);
useEffect(() => {
if (anglePanelOpen) return;
anglePreviewDragRef.current = null;
setIsAnglePreviewDragging(false);
}, [anglePanelOpen]);
useEffect(() => {
if (!hasCompareSource || isThisNodePicking) {
setIsCompareMode(false);
}
}, [hasCompareSource, isThisNodePicking]);
useEffect(() => {
if (
(isThisNodePicking ||
isCompareMode ||
sceneFusionDialogOpen ||
promptDialogOpen ||
notePanelOpen) &&
(boxCutoutMode || cropPanelOpen || localEditMode)
) {
resetBoxCutoutState();
resetLocalEditState();
setCropPanelOpen(false);
setCropInteraction(null);
}
}, [
boxCutoutMode,
cropPanelOpen,
isCompareMode,
isThisNodePicking,
localEditMode,
notePanelOpen,
promptDialogOpen,
resetBoxCutoutState,
resetLocalEditState,
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(() => {
if (!localEditMode || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (isLocalEditControlTarget(event.target)) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
resetLocalEditState();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [localEditMode, resetLocalEditState]);
useEffect(() => {
if (!cropPanelOpen || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setCropPanelOpen(false);
setCropInteraction(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [cropPanelOpen]);
useEffect(() => {
setLoadedResolution(null);
}, [data.src]);
useEffect(() => {
setHasEnteredViewport(false);
}, [data.src]);
useEffect(() => {
if (!data.src || hasEnteredViewport || typeof window === "undefined") {
return;
}
const element = containerRef.current;
if (!element || typeof IntersectionObserver === "undefined") {
setHasEnteredViewport(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setHasEnteredViewport(true);
observer.disconnect();
}
},
{
root: null,
// 轻微提前加载,保证拖动画布时体验顺滑,但不请求远离视口的图片。
rootMargin: "320px",
threshold: 0.01,
}
);
observer.observe(element);
return () => observer.disconnect();
}, [data.src, hasEnteredViewport]);
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 ||
localEditMode
) {
return;
}
const toolbarEl = toolbarRef.current;
if (!toolbarEl) return;
let rafId: number | null = null;
const measure = () => {
const next = {
width: toolbarEl.offsetWidth,
height: toolbarEl.offsetHeight,
};
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,
localEditMode,
notePanelOpen,
promptDialogOpen,
sceneFusionDialogOpen,
]);
useEffect(() => {
if (
typeof window === "undefined" ||
!isSelected ||
isMultiSelected ||
!data.src ||
(!boxCutoutMode && !localEditMode)
) {
return;
}
const toolbarEl = boxCutoutToolbarRef.current;
if (!toolbarEl) return;
let rafId: number | null = null;
const measure = () => {
const next = {
width: toolbarEl.offsetWidth,
height: toolbarEl.offsetHeight,
};
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, localEditMode]);
// 🆕 处理图片点击标注
const handlePickerClick = useCallback(
async (e: React.MouseEvent<HTMLImageElement>) => {
if (!isThisNodePicking || isPickerLoading || !data.src) return;
e.stopPropagation();
e.preventDefault();
const pointerResult = getBoxCutoutPointerPoint(e.clientX, e.clientY);
if (!pointerResult?.insideImage) {
toast.info("请点击图片内容区域");
return;
}
const clickX = pointerResult.point.x;
const clickY = pointerResult.point.y;
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,
getBoxCutoutPointerPoint,
node.id,
setPickerLoading,
addPendingPickedObject,
setPickerMode,
]
);
// 🆕 取消标注
const handleCancelPicking = useCallback(() => {
setPickerMode(false);
}, [setPickerMode]);
const handleStartPicking = useCallback(
(event?: React.MouseEvent) => {
event?.stopPropagation();
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
resetLocalEditState();
setPickerMode(true, node.id);
toast.info("点击图片中的物品进行拾取");
},
[data.src, isProcessing, node.id, resetLocalEditState, 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);
resetLocalEditState();
setBoxCutoutSelection(null);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
setIsBoxCutoutDrawing(false);
setBoxCutoutMode(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
},
[
data.src,
isProcessing,
isThisNodePicking,
refreshBoxCutoutMetrics,
resetLocalEditState,
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 handleEnterLocalEditMode = useCallback(
(
event?: React.MouseEvent | React.PointerEvent,
operationMode: CanvasLocalEditOperationMode = "local_edit"
) => {
event?.stopPropagation();
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("局部编辑需要使用已上传到云端的图片");
return;
}
if (isProcessing || isSubmittingLocalEdit) {
toast.info("正在处理中,请稍候...");
return;
}
if (isThisNodePicking) {
setPickerMode(false);
}
setIsCompareMode(false);
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setLocalEditRegions(getLocalEditRegionsFromMetadata(data.metadata));
setLocalEditOperationMode(operationMode);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
setLocalEditMode(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
toast.info(
operationMode === "scale_adjust"
? "先框选要调整比例的主体,再框选青色目标大小和位置"
: "拖拽框选要局部编辑的位置,并在框下方填写提示词"
);
},
[
data.metadata,
data.src,
isProcessing,
isSubmittingLocalEdit,
isThisNodePicking,
refreshBoxCutoutMetrics,
resetBoxCutoutState,
setPickerMode,
]
);
const handleLocalEditPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!localEditMode || isProcessing || isSubmittingLocalEdit) return;
event.preventDefault();
event.stopPropagation();
const pendingTargetRegion =
localEditOperationMode === "scale_adjust"
? localEditRegions.find((region) => !region.targetRect)
: null;
if (!pendingTargetRegion && localEditRegions.length >= LOCAL_EDIT_MAX_REGIONS) {
toast.info(`局部编辑最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域`);
return;
}
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult?.insideImage) return;
setEditingLocalEditRegionId(null);
setLocalEditDrawingTargetRegionId(pendingTargetRegion?.id || null);
setLocalEditDragStart(pointerResult.point);
setLocalEditDragEnd(pointerResult.point);
setIsLocalEditDrawing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
getBoxCutoutPointerPoint,
isProcessing,
isSubmittingLocalEdit,
localEditMode,
localEditOperationMode,
localEditRegions,
localEditRegions.length,
]
);
const handleLocalEditPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!localEditMode || !isLocalEditDrawing) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult) return;
setLocalEditDragEnd(pointerResult.point);
},
[getBoxCutoutPointerPoint, isLocalEditDrawing, localEditMode]
);
const finalizeLocalEditRegion = useCallback(
(clientX: number, clientY: number) => {
if (!localEditMode || !localEditDragStart) {
setIsLocalEditDrawing(false);
return;
}
const pointerResult = getBoxCutoutPointerPoint(clientX, clientY);
if (!pointerResult) {
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
return;
}
const nextRect = createNormalizedRect(
localEditDragStart,
pointerResult.point
);
const nextWidthPx = nextRect.width * pointerResult.metrics.naturalWidth;
const nextHeightPx =
nextRect.height * pointerResult.metrics.naturalHeight;
if (
nextWidthPx >= LOCAL_EDIT_MIN_EDGE_PX &&
nextHeightPx >= LOCAL_EDIT_MIN_EDGE_PX
) {
if (localEditDrawingTargetRegionId) {
const nextRegions = reindexLocalEditRegions(
localEditRegions.map((region) => {
if (region.id !== localEditDrawingTargetRegionId) return region;
return {
...region,
targetRect: nextRect,
prompt: getCanvasScaleAdjustRegionPrompt(null, true),
};
})
);
commitLocalEditRegions(nextRegions);
toast.info("已设置青色目标框,可拖动它继续调整大小和位置");
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
return;
}
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const nextRegion: LocalEditRegion = {
id: createLocalEditRegionId(),
index: localEditRegions.length + 1,
color:
LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length],
prompt: isScaleAdjustMode
? "比例调整"
: "",
rect: nextRect,
referenceImages: [],
createdAt: Date.now(),
};
commitLocalEditRegions([...localEditRegions, nextRegion]);
setEditingLocalEditRegionId(isScaleAdjustMode ? null : nextRegion.id);
if (isScaleAdjustMode) {
setLocalEditDrawingTargetRegionId(nextRegion.id);
toast.info("继续拖拽框选青色目标大小和位置");
}
} else {
toast.info("框选区域太小,请至少拖出 24px 以上的区域");
}
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
},
[
commitLocalEditRegions,
getBoxCutoutPointerPoint,
localEditDragStart,
localEditDrawingTargetRegionId,
localEditMode,
localEditOperationMode,
localEditRegions,
]
);
const handleLocalEditPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!localEditMode) return;
event.preventDefault();
event.stopPropagation();
finalizeLocalEditRegion(event.clientX, event.clientY);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[finalizeLocalEditRegion, localEditMode]
);
const handleLocalEditPointerCancel = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!localEditMode) return;
event.preventDefault();
event.stopPropagation();
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[localEditMode]
);
const updateLocalEditRegionRect = useCallback(
(
regionId: string,
rect: NormalizedCropRect,
target: LocalEditRegionInteractionState["target"] = "source",
shouldCommit = false
) => {
const nextRegions = reindexLocalEditRegions(
localEditRegionsRef.current.map((region) => {
if (region.id !== regionId) return region;
if (target === "scaleTarget") {
return {
...region,
targetRect: rect,
prompt: getCanvasScaleAdjustRegionPrompt(null, true),
};
}
return { ...region, rect };
})
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
if (shouldCommit) {
commitLocalEditRegions(nextRegions);
}
},
[commitLocalEditRegions]
);
const handleLocalEditRegionPointerDown = useCallback(
(
event: React.PointerEvent<HTMLDivElement>,
region: LocalEditRegion,
target: LocalEditRegionInteractionState["target"] = "source",
targetRect?: NormalizedCropRect
) => {
if (!localEditMode || isProcessing || isSubmittingLocalEdit) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult?.insideImage) return;
const handleElement = (event.target as HTMLElement | null)?.closest(
"[data-local-edit-region-handle]"
) as HTMLElement | null;
const handle =
(handleElement?.dataset.localEditRegionHandle as
| CropInteractionHandle
| undefined) || "move";
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setEditingLocalEditRegionId(null);
setLocalEditRegionInteraction({
handle,
pointerId: event.pointerId,
regionId: region.id,
target,
startPoint: pointerResult.point,
startRect: target === "scaleTarget" ? targetRect || region.rect : region.rect,
});
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
getBoxCutoutPointerPoint,
isProcessing,
isSubmittingLocalEdit,
localEditMode,
]
);
const handleLocalEditRegionPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>, regionId: string) => {
if (
!localEditMode ||
!localEditRegionInteraction ||
localEditRegionInteraction.regionId !== regionId ||
localEditRegionInteraction.pointerId !== event.pointerId
) {
return;
}
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult) return;
const minWidth = Math.min(
LOCAL_EDIT_MIN_EDGE_PX / Math.max(pointerResult.metrics.naturalWidth, 1),
1
);
const minHeight = Math.min(
LOCAL_EDIT_MIN_EDGE_PX / Math.max(pointerResult.metrics.naturalHeight, 1),
1
);
const nextRect = updateCropRectWithPointer({
aspectRatio: null,
handle: localEditRegionInteraction.handle,
minHeight,
minWidth,
pointer: pointerResult.point,
startPoint: localEditRegionInteraction.startPoint,
startRect: localEditRegionInteraction.startRect,
});
updateLocalEditRegionRect(
regionId,
nextRect,
localEditRegionInteraction.target
);
},
[
getBoxCutoutPointerPoint,
localEditMode,
localEditRegionInteraction,
updateLocalEditRegionRect,
]
);
const handleLocalEditRegionPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>, regionId: string) => {
if (
!localEditRegionInteraction ||
localEditRegionInteraction.regionId !== regionId ||
localEditRegionInteraction.pointerId !== event.pointerId
) {
return;
}
event.preventDefault();
event.stopPropagation();
setLocalEditRegionInteraction(null);
commitLocalEditRegions(localEditRegionsRef.current);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[commitLocalEditRegions, localEditRegionInteraction]
);
const handleLocalEditRegionPointerCancel = useCallback(
(event: React.PointerEvent<HTMLDivElement>, regionId: string) => {
if (
!localEditRegionInteraction ||
localEditRegionInteraction.regionId !== regionId ||
localEditRegionInteraction.pointerId !== event.pointerId
) {
return;
}
event.preventDefault();
event.stopPropagation();
setLocalEditRegionInteraction(null);
commitLocalEditRegions(localEditRegionsRef.current);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[commitLocalEditRegions, localEditRegionInteraction]
);
const persistLocalEditRegionPrompt = useCallback(
(regionId: string, prompt: string) => {
updateLocalEditPromptDraft(regionId, prompt);
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId ? { ...region, prompt } : region
)
);
},
[commitLocalEditRegions, updateLocalEditPromptDraft]
);
const deleteLocalEditRegion = useCallback(
(regionId: string) => {
delete localEditPromptDraftRefs.current[regionId];
delete localEditPromptCompositionStartRefs.current[regionId];
setLocalEditPromptDrafts((current) => {
if (!(regionId in current)) return current;
const next = { ...current };
delete next[regionId];
return next;
});
commitLocalEditRegions(
localEditRegionsRef.current.filter((region) => region.id !== regionId)
);
if (editingLocalEditRegionId === regionId) {
setEditingLocalEditRegionId(null);
}
},
[commitLocalEditRegions, editingLocalEditRegionId]
);
const handleLocalEditReferenceUpload = useCallback(
async (regionId: string, fileList: FileList | null) => {
const files = Array.from(fileList || []).filter((file) =>
file.type.startsWith("image/")
);
if (files.length === 0) return;
const currentRegion = localEditRegionsRef.current.find(
(region) => region.id === regionId
);
if (!currentRegion) return;
const currentReferences = currentRegion.referenceImages || [];
const remainingSlots =
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION - currentReferences.length;
if (remainingSlots <= 0) {
toast.info(
`每个局部编辑区域最多添加 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
);
return;
}
const selectedFiles = files.slice(0, remainingSlots);
if (files.length > selectedFiles.length) {
toast.info(
`已自动保留前 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
);
}
setUploadingLocalEditReferenceRegionId(regionId);
try {
const uploadedReferences = await Promise.all(
selectedFiles.map(async (file) => {
const uploaded = await uploadChatAttachmentBlob(file, file.name);
return {
id: createLocalEditReferenceImageId(),
url: uploaded.url,
name: file.name,
source: "upload" as const,
createdAt: Date.now(),
} satisfies LocalEditReferenceImage;
})
);
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId
? {
...region,
referenceImages: [
...(region.referenceImages || []),
...uploadedReferences,
].slice(0, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION),
}
: region
)
);
toast.success(
uploadedReferences.length > 1
? `已添加 ${uploadedReferences.length} 张区域参考图`
: "已添加区域参考图"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setUploadingLocalEditReferenceRegionId(null);
}
},
[commitLocalEditRegions]
);
const removeLocalEditReferenceImage = useCallback(
(regionId: string, referenceId: string) => {
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId
? {
...region,
referenceImages: (region.referenceImages || []).filter(
(reference) => reference.id !== referenceId
),
}
: region
)
);
},
[commitLocalEditRegions]
);
const clearLocalEditRegions = useCallback(() => {
localEditPromptDraftRefs.current = {};
localEditPromptCompositionStartRefs.current = {};
setLocalEditPromptDrafts({});
commitLocalEditRegions([]);
setEditingLocalEditRegionId(null);
}, [commitLocalEditRegions]);
const handleSubmitLocalEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("局部编辑需要使用已上传到云端的图片");
return;
}
if (isSubmittingLocalEdit || isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const validRegions = reindexLocalEditRegions(
localEditRegions.map((region) => {
if (isScaleAdjustMode) {
const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
);
return {
...region,
prompt: getCanvasScaleAdjustRegionPrompt(regionScalePercent, true),
};
}
return {
...region,
prompt: (
localEditPromptDraftRefs.current[region.id] ?? region.prompt
).trim(),
};
})
);
if (validRegions.length === 0) {
toast.error("请先框选至少一个局部编辑区域");
return;
}
if (isScaleAdjustMode) {
const missingTargetRegion = validRegions.find((region) => !region.targetRect);
if (missingTargetRegion) {
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
return;
}
const unchangedRegion = validRegions.find((region) => {
const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
);
return Math.abs(regionScalePercent) < 1;
});
if (unchangedRegion) {
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小`);
return;
}
} else {
const emptyRegion = validRegions.find((region) => !region.prompt);
if (emptyRegion) {
setEditingLocalEditRegionId(emptyRegion.id);
toast.error(`请填写区域 ${emptyRegion.index} 的提示词`);
return;
}
}
const actionName = isScaleAdjustMode ? "比例调整" : "局部编辑";
const editMode = isScaleAdjustMode ? "scale-adjust" : "local-edit";
const scalePercent = isScaleAdjustMode
? deriveCanvasScalePercentFromRects(
validRegions[0].rect,
validRegions[0].targetRect
)
: undefined;
setIsSubmittingLocalEdit(true);
commitLocalEditRegions(validRegions);
resetLocalEditState();
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: {
editMode,
scalePercent,
sourceNodeId: node.id,
localEditMode: false,
sourceLocalEditRegions: validRegions,
},
},
connections: [node.id],
});
selectNode(newNodeId);
try {
const submitResult = await imageGenerationService.localEdit({
sourceImageUrl: data.src,
sourceNodeId: node.id,
operation: isScaleAdjustMode ? "scale-adjust" : undefined,
regions: validRegions.map((region) => ({
id: region.id,
index: region.index,
color: region.color,
prompt: region.prompt,
rect: region.rect,
targetRect: region.targetRect,
referenceImages: (region.referenceImages || []).map((reference) => ({
id: reference.id,
url: reference.url,
name: reference.name,
source: reference.source,
})),
})),
});
if (!submitResult.success || !submitResult.data?.taskId) {
throw new Error(submitResult.error || "局部编辑提交失败");
}
const taskId = submitResult.data.taskId;
const localEditDisplayPrompt =
submitResult.data.displayPrompt || submitResult.data.prompt || `${actionName}处理中...`;
const localEditSourceWidth = Number(submitResult.data.sourceWidth || 0);
const localEditSourceHeight = Number(submitResult.data.sourceHeight || 0);
const localEditSourceRatio =
localEditSourceWidth > 0 && localEditSourceHeight > 0
? localEditSourceWidth / localEditSourceHeight
: 0;
const localEditResultSize =
localEditSourceRatio > 0
? {
width: node.size.width,
height: Math.max(80, Math.round(node.size.width / localEditSourceRatio)),
}
: node.size;
updateNode(newNodeId, {
size: localEditResultSize,
data: {
src: "",
status: "generating",
prompt: localEditDisplayPrompt,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
editMode,
sourceNodeId: node.id,
scalePercent,
aspectRatio: submitResult.data.aspectRatio,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
localEditMode: false,
sourceHeight: submitResult.data.sourceHeight,
sourceLocalEditRegions: validRegions,
sourceWidth: submitResult.data.sourceWidth,
},
},
});
for (let attempt = 0; attempt < LOCAL_EDIT_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(LOCAL_EDIT_POLL_INTERVAL_MS);
const taskResult = await imageGenerationService.getImageTask(taskId);
const task = taskResult.data?.task;
const status = String(task?.status || "").toLowerCase();
if (!taskResult.success || !task) {
continue;
}
if (status === "succeeded" || status === "completed") {
const generatedImage = task.result?.images?.[0];
const generatedUrl =
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
if (!generatedUrl) {
throw new Error("局部编辑已完成但未返回图片");
}
updateNode(newNodeId, {
data: {
src: generatedUrl,
status: "completed",
prompt:
task.result?.prompt ||
submitResult.data.displayPrompt ||
submitResult.data.prompt ||
`${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
model: task.result?.model || task.result?.modelRef || undefined,
metadata: {
editMode,
sourceNodeId: node.id,
scalePercent,
aspectRatio: submitResult.data.aspectRatio,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
localEditMode: false,
localEditRegions: [],
sourceHeight: submitResult.data.sourceHeight,
sourceLocalEditRegions: validRegions,
sourceWidth: submitResult.data.sourceWidth,
},
},
});
resetLocalEditState();
toast.success(`${actionName}结果已生成`);
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "局部编辑生成失败");
}
}
throw new Error("局部编辑任务等待超时,请稍后重试");
} catch (error) {
const message = error instanceof Error ? error.message : "局部编辑失败";
console.error("[Canvas Local Edit] failed:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
editMode,
sourceNodeId: node.id,
scalePercent,
error: message,
localEditMode: false,
sourceLocalEditRegions: validRegions,
},
},
});
toast.error(message);
} finally {
setIsProcessing(false);
setIsSubmittingLocalEdit(false);
}
}, [
addNode,
commitLocalEditRegions,
data.src,
displayTitle,
isProcessing,
isSubmittingLocalEdit,
localEditOperationMode,
localEditRegions,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetLocalEditState,
selectNode,
updateNode,
]);
const handleConfirmBoxCutout = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!boxCutoutSelection) {
toast.info("请先框选要抠出的元素");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setIsCreatingDetachedResultNode(true);
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 {
setIsCreatingDetachedResultNode(false);
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 (previewUrl?: string) => {
if (!containerRef.current || 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 animationImageSrc = isHttpImageUrl(previewUrl)
? buildImageProxyUrl(previewUrl || "", {
width: 360,
quality: 76,
format: "webp",
})
: isHttpImageUrl(data.src)
? buildImageProxyUrl(data.src, {
width: 360,
quality: 76,
format: "webp",
})
: "";
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";
if (animationImageSrc) {
const previewImage = document.createElement("img");
previewImage.src = animationImageSrc;
previewImage.alt = "";
previewImage.style.width = "100%";
previewImage.style.height = "100%";
previewImage.style.objectFit = "cover";
flightTag.appendChild(previewImage);
} else {
const placeholder = document.createElement("div");
placeholder.style.width = "100%";
placeholder.style.height = "100%";
placeholder.style.background =
"linear-gradient(135deg, rgba(15,23,42,0.06), rgba(59,130,246,0.14))";
flightTag.appendChild(placeholder);
}
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(animationImageSrc ? "img" : "span");
if (animationImageSrc && chipImage instanceof HTMLImageElement) {
chipImage.src = animationImageSrc;
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)";
chipImage.style.background = "rgba(59, 130, 246, 0.12)";
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 {
const attachment = await resolveChatAttachmentFromImageSrc(
data.src,
displayTitle
);
await animateAddToChat(attachment.url);
const metadata = (data.metadata || {}) as Record<string, unknown>;
const sourceWidth =
Number(metadata.originalWidth || 0) ||
Number(node.size?.width || 0) ||
0;
const sourceHeight =
Number(metadata.originalHeight || 0) ||
Number(node.size?.height || 0) ||
0;
addPendingChatAttachment({
url: attachment.url,
name: displayTitle,
contentType: attachment.contentType,
width:
Number.isFinite(sourceWidth) && sourceWidth > 0
? Math.round(sourceWidth)
: undefined,
height:
Number.isFinite(sourceHeight) && sourceHeight > 0
? Math.round(sourceHeight)
: undefined,
});
toast.success("已添加到对话框");
} catch (error) {
console.error("[CanvasImageNode] add to chat failed:", error);
toast.error(error instanceof Error ? error.message : "添加到对话失败");
} finally {
setIsAddingToChat(false);
}
}, [
addPendingChatAttachment,
animateAddToChat,
data.metadata,
data.src,
displayTitle,
isAddingToChat,
isProcessing,
node.size?.height,
node.size?.width,
]);
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 toolbarWidth = Math.max(
280,
(toolbarSize.width || 640) * FLOATING_TOOLBAR_SCALE
);
const toolbarHeight = Math.max(
42,
(toolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
);
const titleSafeHeight = 34;
const gap = 12;
const { viewportPadding, maxTop, safeBottom, safeLeft, safeRight } =
getFloatingViewportBounds(toolbarHeight);
let top = rect.bottom + gap;
if (top + toolbarHeight > safeBottom) {
top = rect.top - toolbarHeight - titleSafeHeight - gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
let left = rect.left + rect.width / 2 - effectiveToolbarWidth / 2;
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = safeLeft;
} else {
left = Math.min(
Math.max(left, safeLeft),
safeRight - effectiveToolbarWidth
);
}
return {
top,
left,
maxWidth: maxToolbarWidth,
};
}
return { top: 0, left: 0, maxWidth: 480 };
};
const getAiWashQuickPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0, width: 286, maxHeight: 430 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 286;
const panelHeight = 430;
const gap = 12;
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
getFloatingViewportBounds(panelHeight);
const width = Math.min(panelWidth, Math.max(260, safeRight - safeLeft));
let left = rect.right + gap;
if (left + width > safeRight) {
left = rect.left - width - gap;
}
left = Math.min(Math.max(left, safeLeft), Math.max(safeLeft, safeRight - width));
let top = rect.top;
if (top + panelHeight > safeBottom) {
top = rect.bottom - panelHeight;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
return {
top,
left,
width,
maxHeight: Math.max(260, Math.min(panelHeight, safeBottom - top)),
};
};
const getBoxCutoutToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarWidth = Math.max(
240,
(boxCutoutToolbarSize.width || 460) * FLOATING_TOOLBAR_SCALE
);
const toolbarHeight = Math.max(
42,
(boxCutoutToolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
);
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);
let left = rect.left + rect.width / 2 - effectiveToolbarWidth / 2;
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = safeLeft;
} else {
left = Math.min(
Math.max(left, safeLeft),
safeRight - effectiveToolbarWidth
);
}
return {
top,
left,
maxWidth: maxToolbarWidth,
};
}
return { top: 0, left: 0, maxWidth: 460 };
};
const getImageAdjustPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 520 * IMAGE_EDIT_PANEL_SCALE;
const panelHeight = 760 * IMAGE_EDIT_PANEL_SCALE;
const spacing = 18;
const maxTop = Math.max(12, window.innerHeight - panelHeight);
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 + 10), maxTop);
return { top, left };
};
const getCropPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 474 * IMAGE_EDIT_PANEL_SCALE;
const panelHeight = 980 * IMAGE_EDIT_PANEL_SCALE;
const spacing = 16;
const maxTop = Math.max(12, window.innerHeight - panelHeight);
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 - 4), maxTop);
return { top, left };
};
const getFlipRotatePanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0, maxWidth: 660 * IMAGE_EDIT_PANEL_SCALE };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 640 * IMAGE_EDIT_PANEL_SCALE;
const panelHeight = 84 * IMAGE_EDIT_PANEL_SCALE;
const gap = 18;
const titleSafeHeight = 34;
const { viewportPadding, safeLeft, safeRight, maxTop } =
getFloatingViewportBounds(panelHeight);
let top = rect.top - panelHeight - titleSafeHeight - gap;
if (top < viewportPadding) {
top = rect.bottom + gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxPanelWidth = Math.max(220, safeRight - safeLeft);
const effectivePanelWidth = Math.min(panelWidth, maxPanelWidth);
let left = rect.left + rect.width / 2 - effectivePanelWidth / 2;
if (effectivePanelWidth >= maxPanelWidth - 1) {
left = safeLeft;
} else {
left = Math.min(Math.max(left, safeLeft), safeRight - effectivePanelWidth);
}
return {
top,
left,
maxWidth: maxPanelWidth,
};
};
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 getAnglePanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = ANGLE_PANEL_SOURCE_WIDTH * ANGLE_PANEL_SCALE;
const panelHeight = ANGLE_PANEL_SOURCE_HEIGHT * ANGLE_PANEL_SCALE;
const spacing = 16;
const maxTop = Math.max(12, window.innerHeight - panelHeight);
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 - 8), maxTop);
return { top, left };
};
const handleResetAnglePanel = useCallback(() => {
setAnglePanelMode("camera");
setAngleHorizontalAngle(-50);
setAngleVerticalAngle(-30);
setAngleZoom(50);
}, []);
const handleAnglePreviewPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isApplyingAngleEdit) return;
anglePreviewDragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startHorizontalAngle: angleHorizontalAngle,
startVerticalAngle: angleVerticalAngle,
};
setIsAnglePreviewDragging(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
event.preventDefault();
event.stopPropagation();
},
[angleHorizontalAngle, angleVerticalAngle, isApplyingAngleEdit]
);
const handleAnglePreviewPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const dragState = anglePreviewDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
setAngleHorizontalAngle(
clampNumber(
dragState.startHorizontalAngle +
deltaX * ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER,
-180,
180
)
);
setAngleVerticalAngle(
clampNumber(
dragState.startVerticalAngle -
deltaY * ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER,
-30,
60
)
);
event.preventDefault();
event.stopPropagation();
},
[]
);
const handleAnglePreviewPointerEnd = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (anglePreviewDragRef.current?.pointerId !== event.pointerId) return;
anglePreviewDragRef.current = null;
setIsAnglePreviewDragging(false);
event.currentTarget.releasePointerCapture?.(event.pointerId);
event.preventDefault();
event.stopPropagation();
},
[]
);
const handleApplyFlipRotate = useCallback(
async (action: FlipRotateEditAction) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isApplyingFlipRotate) {
return;
}
if (action === "reset") {
if (!hasFlipRotateReset) {
toast.info("当前图片没有可恢复的翻转记录");
return;
}
const nextMetadata =
data.metadata && typeof data.metadata === "object"
? { ...(data.metadata as Record<string, unknown>) }
: {};
delete nextMetadata.flipRotateOriginalSrc;
delete nextMetadata.flipRotateLastAction;
delete nextMetadata.flipRotateUpdatedAt;
updateNode(node.id, {
data: {
...data,
src: flipRotateOriginalSrc,
status: "completed",
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
},
});
toast.success("已恢复翻转前图片");
return;
}
setIsApplyingFlipRotate(true);
setIsProcessing(true);
try {
const nextUrl = await renderAndUploadFlippedImage(
data.src,
action,
`${displayTitle}-${action === "horizontal" ? "水平翻转" : "垂直翻转"}`
);
updateNode(node.id, {
data: {
...data,
src: nextUrl,
status: "completed",
originalSrc: data.originalSrc || data.src,
metadata: {
...(data.metadata || {}),
flipRotateOriginalSrc: flipRotateOriginalSrc || data.src,
flipRotateLastAction: action,
flipRotateUpdatedAt: new Date().toISOString(),
},
},
});
toast.success(action === "horizontal" ? "已完成水平翻转" : "已完成垂直翻转");
} catch (error) {
const message = error instanceof Error ? error.message : "翻转失败";
toast.error(message);
} finally {
setIsApplyingFlipRotate(false);
setIsProcessing(false);
}
},
[
data,
displayTitle,
flipRotateOriginalSrc,
hasFlipRotateReset,
isApplyingFlipRotate,
node.id,
updateNode,
]
);
const handleApplyAngleEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isApplyingAngleEdit) {
return;
}
setIsApplyingAngleEdit(true);
setIsCreatingDetachedResultNode(true);
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: "多视角处理中...",
title: `${displayTitle}-多视角`,
originalSrc: data.src,
metadata: {
angleEditSettings: {
mode: anglePanelMode,
horizontalAngle: Math.round(angleHorizontalAngle),
verticalAngle: Math.round(angleVerticalAngle),
zoom: Math.round(angleZoom),
},
},
},
connections: [node.id],
});
try {
const result = await imageGenerationService.editAngle({
imageUrl: data.src,
horizontalAngle: normalizeAnglePanelHorizontalAngle(
Math.round(angleHorizontalAngle)
),
verticalAngle: Math.round(angleVerticalAngle),
zoom: mapAnglePanelZoomToApi(angleZoom),
});
if (!result.success || !result.data?.url) {
throw new Error(result.error || "多视角生成失败");
}
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: result.data.prompt || "多视角结果",
title: `${displayTitle}-多视角`,
originalSrc: data.src,
metadata: {
angleEditSettings: {
mode: anglePanelMode,
horizontalAngle: Math.round(angleHorizontalAngle),
verticalAngle: Math.round(angleVerticalAngle),
zoom: Math.round(angleZoom),
},
angles: result.data.angles || null,
},
},
});
updateNode(node.id, {
data: {
...data,
metadata: {
...(data.metadata || {}),
angleEditSettings: {
mode: anglePanelMode,
horizontalAngle: Math.round(angleHorizontalAngle),
verticalAngle: Math.round(angleVerticalAngle),
zoom: Math.round(angleZoom),
},
},
},
});
selectNode(newNodeId);
setAnglePanelOpen(false);
toast.success("多视角结果已生成");
} catch (error) {
const message =
error instanceof Error ? error.message : "多视角生成失败";
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "多视角生成失败",
title: `${displayTitle}-多视角失败`,
originalSrc: data.src,
},
});
toast.error(message);
} finally {
setIsApplyingAngleEdit(false);
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
addNode,
angleHorizontalAngle,
anglePanelMode,
angleVerticalAngle,
angleZoom,
data,
displayTitle,
isApplyingAngleEdit,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
selectNode,
updateNode,
]);
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 handleOpenImageAdjustPanel = useCallback(() => {
setCropPanelOpen(false);
setCropInteraction(null);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setImageAdjustTab("light");
setImageAdjustmentsDraft(storedImageAdjustments);
setImageAdjustPanelOpen(true);
}, [resetBoxCutoutState, storedImageAdjustments]);
const handleResetImageAdjustments = useCallback(() => {
setImageAdjustmentsDraft(DEFAULT_IMAGE_ADJUSTMENTS);
}, []);
const handleOpenCropPanel = useCallback(() => {
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setCropInteraction(null);
setCropPanelOpen(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
}, [refreshBoxCutoutMetrics, resetBoxCutoutState]);
const handleToggleCropGroup = useCallback((groupKey: CropPanelGroupKey) => {
setCropExpandedGroups((prev) => ({
...prev,
[groupKey]: !prev[groupKey],
}));
}, []);
const handleSelectCropPreset = useCallback((presetId: string) => {
const preset = getCropPresetById(presetId);
if (!preset) return;
const centerX = cropRect ? cropRect.x + cropRect.width / 2 : 0.5;
const centerY = cropRect ? cropRect.y + cropRect.height / 2 : 0.5;
setCropPresetId(preset.id);
setCropLockAspectRatio(true);
setCropRect(createCenteredCropRectForAspect(preset.ratio, centerX, centerY));
}, [cropRect]);
const handleChangeCropDimension = useCallback(
(dimension: "width" | "height", rawValue: string) => {
if (!cropRect || !cropImageMetrics) return;
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < BOX_CUTOUT_MIN_EDGE_PX) {
return;
}
const naturalWidth = cropImageMetrics.naturalWidth;
const naturalHeight = cropImageMetrics.naturalHeight;
if (!naturalWidth || !naturalHeight) return;
let nextWidth = dimension === "width" ? parsed : cropPixelSize.width;
let nextHeight = dimension === "height" ? parsed : cropPixelSize.height;
const lockedAspect =
cropLockAspectRatio &&
(selectedCropPreset?.ratio ||
(currentCropAspectRatio && Number.isFinite(currentCropAspectRatio)
? currentCropAspectRatio
: null));
if (lockedAspect) {
if (dimension === "width") {
nextHeight = Math.round(nextWidth / lockedAspect);
} else {
nextWidth = Math.round(nextHeight * lockedAspect);
}
}
nextWidth = clampNumber(nextWidth, BOX_CUTOUT_MIN_EDGE_PX, naturalWidth);
nextHeight = clampNumber(nextHeight, BOX_CUTOUT_MIN_EDGE_PX, naturalHeight);
const centerX = cropRect.x + cropRect.width / 2;
const centerY = cropRect.y + cropRect.height / 2;
let normalizedWidth = nextWidth / naturalWidth;
let normalizedHeight = nextHeight / naturalHeight;
if (lockedAspect) {
const fitted = createCenteredCropRectForAspect(lockedAspect, centerX, centerY);
const maxWidth = fitted.width * naturalWidth;
const maxHeight = fitted.height * naturalHeight;
if (nextWidth > maxWidth || nextHeight > maxHeight) {
normalizedWidth = fitted.width;
normalizedHeight = fitted.height;
}
}
setCropRect(
createCenteredCropRectFromSize(
centerX,
centerY,
normalizedWidth,
normalizedHeight
)
);
},
[
cropImageMetrics,
cropLockAspectRatio,
cropPixelSize.height,
cropPixelSize.width,
cropRect,
currentCropAspectRatio,
selectedCropPreset,
]
);
const handleCropPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!cropRect || !cropPanelOpen || isApplyingCrop) return;
const target = event.target as HTMLElement | null;
const interactive = target?.closest<HTMLElement>("[data-crop-handle]");
const handle =
(interactive?.dataset.cropHandle as CropInteractionHandle | undefined) ||
null;
if (!handle) return;
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult?.insideImage) return;
event.preventDefault();
event.stopPropagation();
setCropInteraction({
handle,
pointerId: event.pointerId,
startPoint: pointerResult.point,
startRect: cropRect,
});
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[cropPanelOpen, cropRect, getBoxCutoutPointerPoint, isApplyingCrop]
);
const handleCropPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!cropInteraction || !cropImageMetrics) return;
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult) return;
event.preventDefault();
event.stopPropagation();
const minSize = getNormalizedMinCropSize(
cropImageMetrics.naturalWidth,
cropImageMetrics.naturalHeight
);
setCropRect(
updateCropRectWithPointer({
aspectRatio: effectiveCropAspectRatio,
handle: cropInteraction.handle,
minHeight: minSize.height,
minWidth: minSize.width,
pointer: pointerResult.point,
startPoint: cropInteraction.startPoint,
startRect: cropInteraction.startRect,
})
);
},
[cropImageMetrics, cropInteraction, effectiveCropAspectRatio, getBoxCutoutPointerPoint]
);
const handleCropPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!cropInteraction) return;
event.preventDefault();
event.stopPropagation();
setCropInteraction(null);
event.currentTarget.releasePointerCapture?.(cropInteraction.pointerId);
},
[cropInteraction]
);
const handleCropPointerCancel = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!cropInteraction) return;
event.preventDefault();
event.stopPropagation();
setCropInteraction(null);
event.currentTarget.releasePointerCapture?.(cropInteraction.pointerId);
},
[cropInteraction]
);
const handleApplyCrop = useCallback(async () => {
if (!data.src || !cropRect) {
toast.error("当前没有可用的裁剪区域");
return;
}
if (isApplyingCrop) {
return;
}
setIsApplyingCrop(true);
setIsProcessing(true);
try {
const result = await renderAndUploadCroppedImage(
data.src,
cropRect,
`${displayTitle}-裁剪`
);
updateNode(node.id, {
data: {
...data,
src: result.url,
status: "completed",
originalSrc: data.originalSrc || data.src,
metadata: {
...(data.metadata || {}),
compareSourceUrl: data.src,
cropPresetId,
cropLockAspectRatio,
cropUpdatedAt: new Date().toISOString(),
originalWidth: result.width,
originalHeight: result.height,
},
},
});
setCropPanelOpen(false);
setCropInteraction(null);
setCropRect(null);
toast.success("裁剪已完成");
} catch (error) {
const message = error instanceof Error ? error.message : "裁剪失败";
toast.error(message);
} finally {
setIsApplyingCrop(false);
setIsProcessing(false);
}
}, [
cropLockAspectRatio,
cropPresetId,
cropRect,
data,
displayTitle,
isApplyingCrop,
node.id,
updateNode,
]);
const handleOpenNotePanel = useCallback(() => {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setDraftNote(data.note || "");
setNotePanelOpen(true);
}, [data.note]);
const handleSaveNote = useCallback(() => {
const nextNote = draftNote.trim();
const noteAnnotationMetadata = syncCanvasNodeNoteAnnotationMetadata({
metadata: data.metadata,
nodeId: node.id,
note: nextNote,
});
updateNode(node.id, {
data: {
...data,
note: nextNote || undefined,
metadata:
Object.keys(noteAnnotationMetadata.metadata).length > 0
? noteAnnotationMetadata.metadata
: undefined,
},
});
setDraftNote(nextNote);
setNotePanelOpen(false);
toast.success(nextNote ? "批注已保存" : "批注已清除");
}, [data, draftNote, node.id, updateNode]);
const detectTextItems = useCallback(
async (forceRefresh = 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,
textColor: undefined,
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 () => {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setNotePanelOpen(false);
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 handleOpenPsdTemplateReplace = useCallback(() => {
if (!canvasId) {
toast.error("当前图片未绑定画布,暂时无法打开模版替换");
return;
}
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("图片上传完成后再进行模版替换");
return;
}
setPsdTemplateDialogOpen(true);
}, [canvasId, data.src]);
const handleLaunchPsdTemplateReplace = useCallback(() => {
const normalizedBrandName = psdTemplateBrandName.trim();
if (!canvasId || !data.src || !isHttpImageUrl(data.src)) {
toast.error("当前图片暂时无法进行模版替换");
return;
}
if (!normalizedBrandName) {
toast.error("请先选择或输入品牌名");
return;
}
const token = createCanvasPsdTemplateLaunchToken();
const returnTo =
typeof window !== "undefined"
? `${window.location.pathname}${window.location.search}`
: `/canvas/${canvasId}`;
saveCanvasPsdTemplateLaunchPayload(token, {
brandName: normalizedBrandName,
pendingImages: [
{
id: node.id,
imageUrl: data.src,
title: displayTitle,
},
],
activeImageId: node.id,
returnTo,
templateMode: "main",
createdAt: Date.now(),
});
setPsdTemplateDialogOpen(false);
router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`);
}, [canvasId, data.src, displayTitle, node.id, psdTemplateBrandName, router]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
clearImageNodeActionRequest();
if (imageNodeActionRequest.action === "box-cutout") {
handleEnterBoxCutoutMode();
return;
}
if (imageNodeActionRequest.action === "text-edit") {
void handleOpenTextEdit();
return;
}
if (imageNodeActionRequest.action === "local-edit") {
handleEnterLocalEditMode();
return;
}
if (imageNodeActionRequest.action === "psd-template") {
handleOpenPsdTemplateReplace();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenPsdTemplateReplace,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
]);
const handleTextItemChange = (id: string, value: string) => {
setTextEditItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, editedText: value } : item
)
);
};
const handleTextItemColorChange = (id: string, value?: string) => {
setTextEditItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, textColor: normalizeOptionalHexColor(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 hasTextEditChanges = useMemo(
() =>
textEditItems.some((item) => {
const originalText = item.originalText.trim();
const editedText = item.editedText.trim();
return (
originalText.length > 0 &&
(editedText.length === 0 ||
editedText !== originalText ||
Boolean(normalizeOptionalHexColor(item.textColor)))
);
}) ||
deletedTextItems.some((item) => item.originalText.trim().length > 0),
[deletedTextItems, textEditItems]
);
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,
textColor: normalizeOptionalHexColor(item.textColor),
action,
};
})
.filter(
(item) =>
item.originalText.length > 0 &&
(item.action === "delete" ||
(item.editedText &&
item.editedText.length > 0 &&
(item.originalText !== item.editedText ||
Boolean(item.textColor))))
);
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);
setIsCreatingDetachedResultNode(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);
setIsCreatingDetachedResultNode(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);
window.dispatchEvent(
new CustomEvent(CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT, {
detail: { nodeId: node.id },
})
);
const cleanMetadata = stripImageGenerationMetadata(data.metadata);
// 先显示上传中状态。手动上传不是生图,不能进入 generating 占位态。
updateNode(node.id, {
data: {
...data,
src: "",
status: "uploading",
metadata: cleanMetadata,
},
});
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,
metadata: cleanMetadata,
},
});
toast.success("图片上传成功");
} else {
updateNode(node.id, {
data: {
...data,
src: "",
status: "failed",
metadata: {
...(cleanMetadata || {}),
uploadError: true,
detailProgressLabel: "上传失败",
},
},
});
toast.error(result.error || "图片上传失败");
}
} catch (error) {
console.error("图片上传失败:", error);
updateNode(node.id, {
data: {
...data,
src: "",
status: "failed",
metadata: {
...(cleanMetadata || {}),
uploadError: true,
detailProgressLabel: "上传失败",
},
},
});
toast.error("图片上传失败,请重试");
} finally {
setIsUploading(false);
}
// 重置 input
e.target.value = "";
};
// 获取操作的中文名称
const getActionName = (action: string): string => {
const names: Record<string, string> = {
rmbg: "抠图",
watermark: "去印",
enhance: "增强",
extend: "扩图",
crop: "裁剪",
adjust: "调整",
"flip-rotate": "翻转与旋转",
resize: "调尺",
angle: "视角",
"layer-split": "图层拆分",
"scene-fusion": "批量处理",
annotate: "标注",
"text-edit": "改字",
note: "添加批注",
"prompt-regenerate": "重绘",
"box-cutout": "框选抠图",
"local-edit": "局部编辑",
};
return names[action] || action;
};
const openThreedGenerationDialog = useCallback(
(mode: "model" | "world") => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("请选择一张可访问的 URL 图片");
return;
}
if (mode === "model") {
if (!tripo3dEnabled) {
toast.error("Tripo3D 当前未开启,请先在系统设置中启用");
return;
}
if (tripoModelOptions.length === 0) {
toast.error("当前没有可用的 3D 模型");
return;
}
} else {
if (!worldlabs3dEnabled) {
toast.error("World Labs 当前未开启,请先在系统设置中启用");
return;
}
if (worldModelOptions.length === 0) {
toast.error("当前没有可用的 3D 世界模型");
return;
}
}
setThreedGenerationDialogMode(mode);
},
[
data.src,
tripo3dEnabled,
tripoModelOptions.length,
worldModelOptions.length,
worldlabs3dEnabled,
]
);
useEffect(() => {
if (!angleEditEnabled && anglePanelOpen) {
setAnglePanelOpen(false);
}
}, [angleEditEnabled, anglePanelOpen]);
async function handleConfirmThreedGeneration() {
if (threedGenerationDialogMode === "model") {
const targetModelVersion = selectedTripoModelEntry?.id || "";
if (!targetModelVersion) {
toast.error("当前没有可用的 3D 模型");
return;
}
setThreedGenerationDialogMode(null);
await handleGenerateCanvas3DModel(targetModelVersion);
return;
}
if (threedGenerationDialogMode === "world") {
const targetModelVersion = selectedWorldModelEntry?.id || "";
if (!targetModelVersion) {
toast.error("当前没有可用的 3D 世界模型");
return;
}
setThreedGenerationDialogMode(null);
await handleGenerateCanvas3DWorld(targetModelVersion);
}
}
const handleSubmitAiWashQuick = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src)) {
toast.error("图片仍在本地,上传完成后再进行 AI洗图");
return;
}
if (!aiWashQuickConfig.enabled) {
toast.error("AI洗图当前未开启,请先在系统设置中启用");
return;
}
if (!selectedAiWashQuickPreset) {
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
return;
}
if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) {
toast.error(
selectedAiWashQuickModel?.disabledReason ||
"当前 AI洗图模型不可用,请在后台重新配置模型"
);
return;
}
if (isProcessing || isSubmittingAiWashQuick) {
toast.info("正在处理中,请稍候...");
return;
}
setImageAdjustPanelOpen(false);
setCropPanelOpen(false);
setCropInteraction(null);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
setIsSubmittingAiWashQuick(true);
setAiWashQuickStatus("submitting");
const actionName = aiWashQuickModeLabel || "AI洗图";
const resultTitle = `${displayTitle}-${selectedAiWashQuickPreset.title}`;
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: resultTitle,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
},
},
connections: [node.id],
});
selectNode(newNodeId);
try {
const submitResponse = await fetch("/api/ai-wash/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiWashQuickPrompt,
referenceImage: data.src,
selectedModelId: selectedAiWashQuickModel.id || effectiveAiWashQuickModelRef,
imageSize: aiWashQuickConfig.imageSize,
promptExtend: aiWashQuickConfig.promptExtend,
title: selectedAiWashQuickPreset.title,
}),
});
const submitPayload = await submitResponse.json().catch(() => ({}));
if (
!submitResponse.ok ||
submitPayload?.success !== true ||
!submitPayload?.taskId
) {
throw new Error(submitPayload?.error || "AI洗图提交失败");
}
setAiWashQuickStatus("running");
updateNode(newNodeId, {
data: {
src: "",
status: "generating",
prompt: aiWashQuickPrompt || `${actionName}处理中...`,
title: resultTitle,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
taskId: submitPayload.taskId,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
aiWashImageSize: aiWashQuickConfig.imageSize,
},
},
});
const completed = await pollAiWashQuickTask(submitPayload.taskId);
const resolvedModelLabel =
completed.modelId ||
selectedAiWashQuickModel?.displayName ||
selectedAiWashQuickModel?.modelId ||
effectiveAiWashQuickModelRef;
updateNode(newNodeId, {
data: {
src: completed.imageUrl,
status: "completed",
prompt: completed.prompt || aiWashQuickPrompt || `${actionName}结果`,
title: resultTitle,
originalSrc: data.src,
model: resolvedModelLabel,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
taskId: submitPayload.taskId,
thumbnailUrl: completed.thumbnailUrl,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
aiWashImageSize: aiWashQuickConfig.imageSize,
modelId: resolvedModelLabel,
},
},
});
setAiWashQuickStatus("succeeded");
setAiWashQuickPanelOpen(false);
toast.success("AI洗图结果已生成到画布");
} catch (error) {
const message = error instanceof Error ? error.message : "AI洗图失败";
console.error("[ImageNode] AI洗图快捷生成失败:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${resultTitle}-失败`,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
error: message,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
},
},
});
setAiWashQuickStatus("failed");
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
setIsSubmittingAiWashQuick(false);
}
}, [
addNode,
aiWashQuickConfig.enabled,
aiWashQuickConfig.hideModelSelector,
aiWashQuickConfig.imageSize,
aiWashQuickConfig.promptExtend,
aiWashQuickLevels,
aiWashQuickModeLabel,
aiWashQuickPrompt,
data.src,
displayTitle,
aiWashQuickHasExecutableModel,
effectiveAiWashQuickModelRef,
isProcessing,
isSubmittingAiWashQuick,
node,
resetBoxCutoutState,
resetLocalEditState,
selectNode,
selectedAiWashQuickModel?.disabledReason,
selectedAiWashQuickModel?.displayName,
selectedAiWashQuickModel?.id,
selectedAiWashQuickModel?.modelId,
selectedAiWashQuickPreset,
updateNode,
]);
// 处理图片编辑操作 - 集成我们现有的 API
const handleAction = async (action: string) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (
AI_CLOUD_IMAGE_ACTIONS.has(action) &&
(isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src))
) {
toast.error("图片仍在本地,上传完成后再进行 AI 处理");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
if (action === "ai-wash") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setAiWashQuickPanelOpen((current) => !current);
return;
}
setAiWashQuickPanelOpen(false);
if (action !== "local-edit" && action !== "scale-adjust") {
resetLocalEditState();
}
// 对话框类型的操作
if (action === "crop") {
handleOpenCropPanel();
return;
}
if (action === "adjust") {
handleOpenImageAdjustPanel();
return;
}
if (action === "flip-rotate") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setFlipRotatePanelOpen(true);
return;
}
if (action === "angle") {
if (!angleEditEnabled) {
toast.info("多视角功能未开启");
return;
}
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setAnglePanelOpening(true);
setAnglePanelOpen(true);
return;
}
if (action === "scene-fusion") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
if (!batchProcessingEnabled) {
toast.info("批量处理功能未开启");
return;
}
if (!canvasId) {
toast.error("当前画布上下文缺失,暂时无法打开批量处理页面");
return;
}
router.push(`/canvas/${canvasId}/batch`);
return;
}
if (action === "resize") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setResizeAnnotatorOpen(true);
return;
}
if (action === "layer-split") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
if (!isLayerSplitEnabled) {
toast.info("图层拆分功能未开启");
return;
}
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const result = await imageGenerationService.splitImageLayers(data.src);
const layers = result.data?.layers || [];
if (!result.success || layers.length === 0) {
toast.error(result.error || "图层拆分失败");
return;
}
const addedLayerCount = handleLayerSplitComplete(
layers.map((layer) => ({
originalUrl: layer.originalUrl,
title: layer.title,
layerIndex: layer.layerIndex,
totalLayers: layer.totalLayers,
bounds: layer.bounds ?? null,
}))
);
toast.success(`图层拆分完成,已新增 ${addedLayerCount} 个图层结果`);
} catch (error) {
console.error("图层拆分失败:", error);
toast.error("图层拆分失败");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
return;
}
if (action === "box-cutout") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
handleEnterBoxCutoutMode();
return;
}
if (action === "local-edit") {
handleEnterLocalEditMode();
return;
}
if (action === "psd-template") {
handleOpenPsdTemplateReplace();
return;
}
if (action === "scale-adjust") {
handleEnterLocalEditMode(undefined, "scale_adjust");
return;
}
if (action === "annotate") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnnotatorOpen(true);
return;
}
if (action === "text-edit") {
await handleOpenTextEdit();
return;
}
if (action === "note") {
handleOpenNotePanel();
return;
}
if (action === "prompt-regenerate") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setRegeneratePrompt(resolveRegeneratePromptSeed(data));
setPromptDialogOpen(true);
return;
}
if (action === "generate-3d-model") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
openThreedGenerationDialog("model");
return;
}
if (action === "generate-3d-world") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
openThreedGenerationDialog("world");
return;
}
const actionName = getActionName(action);
setImageAdjustPanelOpen(false);
setCropPanelOpen(false);
setCropInteraction(null);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setIsCreatingDetachedResultNode(true);
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 {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
const requestCanvasAgentAnnotationEdit = useCallback(() => {
if (!hasCanvasAgentAnnotations || typeof window === "undefined") {
toast.info("当前图片还没有可用于 Agent 的批注");
return;
}
const detail = {
nodeId: node.id,
title: displayTitle,
annotationCount: canvasAgentAnnotations.length,
};
const targetWindow = window as CanvasAgentPendingRequestWindow;
selectNode(node.id, false);
storeCanvasAgentAnnotationEditRequest(targetWindow, detail);
dispatchCanvasAgentOpenChatPanelRequest(targetWindow, {
reason: "agent-annotation-edit",
nodeId: detail.nodeId,
});
dispatchCanvasAgentAnnotationEditRequest(targetWindow, detail);
}, [
canvasAgentAnnotations.length,
displayTitle,
hasCanvasAgentAnnotations,
node.id,
selectNode,
]);
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 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 handleLayerSplitComplete = useCallback(
(
layers: Array<{
originalUrl: string;
title?: string;
layerIndex: number;
totalLayers: number;
bounds?: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
} | null;
}>
) => {
const validLayers = layers.filter(
(item) =>
typeof item?.originalUrl === "string" &&
item.originalUrl.trim().length > 0
);
if (validLayers.length === 0) {
throw new Error("没有可添加到画布的图层结果");
}
const isFullFrameLayer = (bounds?: LayerSplitBounds | null) => {
if (!bounds) return false;
return (
bounds.coverage >= 0.92 &&
bounds.width >= bounds.originalWidth - 2 &&
bounds.height >= bounds.originalHeight - 2
);
};
const backgroundLayers = validLayers
.filter((layer) => isFullFrameLayer(layer.bounds))
.sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const subjectLayers = validLayers
.filter((layer) => !isFullFrameLayer(layer.bounds))
.sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const orderedLayers = [...backgroundLayers, ...subjectLayers];
const groupGap = 28;
const groupOffsetX = node.size.width + groupGap;
const groupOffsetY = 0;
orderedLayers.forEach((layer, index) => {
const layerTitle = `${displayTitle}-${layer.title || `图层${layer.layerIndex || index + 1}`}`;
const bounds = layer.bounds;
const fullFrameLayer = isFullFrameLayer(bounds);
const targetPosition = {
x: node.position.x + groupOffsetX,
y: node.position.y + groupOffsetY,
};
const targetSize = { width: node.size.width, height: node.size.height };
addNode({
type: "image",
position: targetPosition,
size: targetSize,
data: {
src: layer.originalUrl,
status: "completed",
prompt: `图层拆分结果 ${layer.layerIndex}/${layer.totalLayers}`,
title: layerTitle,
originalSrc: data.src,
metadata: {
layerIndex: layer.layerIndex,
totalLayers: layer.totalLayers,
bounds: layer.bounds,
allowOverlap: true,
placementMode: "overlay",
layerSplitRole: fullFrameLayer ? "background" : "subject",
},
},
connections: [node.id],
});
});
return orderedLayers.length;
},
[
addNode,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
]
);
// 图片标注完成回调
const handleAnnotateComplete = async (
annotatedImageUrl: string,
payload?: ImageAnnotatorConfirmPayload
) => {
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`);
formData.append("purpose", "canvas-upload");
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;
}
}
const visualAnnotationMetadata = appendCanvasVisualAnnotationsMetadata({
metadata: data.metadata,
nodeId: node.id,
actions: payload?.actions || [],
imageWidth: payload?.imageWidth,
imageHeight: payload?.imageHeight,
});
// 更新当前节点,标记为已标注图片
// 标注后的图片会带有视觉标记(箭头、圆圈、文字等)
// 这些标记指示了用户希望 AI 重点关注/修改的区域
updateNode(node.id, {
data: {
...data,
src: finalUrl,
hasAnnotations: true, // 🆕 标记为已标注
originalSrc: data.originalSrc || data.src, // 保留原始图片
prompt: data.prompt ? `${data.prompt} (已标注)` : "已标注图片",
metadata:
Object.keys(visualAnnotationMetadata.metadata).length > 0
? visualAnnotationMetadata.metadata
: undefined,
},
});
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`);
formData.append("purpose", "canvas-upload");
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 persistCanvasStateSnapshot = useCallback(() => {
if (!projectId || typeof window === "undefined") return;
window.setTimeout(() => {
void canvasService.saveCanvasState(projectId, getCanvasState());
}, 0);
}, [getCanvasState, projectId]);
const persistCanvas3DAsset = useCallback(
async (asset: Record<string, any>) => {
if (!projectId) return;
try {
const response = await fetch(`/api/canvas/${projectId}/model3d-assets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ asset }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error || `HTTP ${response.status}`);
}
} catch (error) {
console.error("[ImageNode] 保存画布 3D 资产失败:", error);
}
},
[projectId]
);
const pollCanvasModel3DTask = useCallback(
async (params: {
nodeId: string;
taskId: string;
providerId?: string | null;
providerName?: string | null;
promptText: string;
modelVersion: string;
sourceImageUrl: string;
previewImageUrl?: string | null;
}) => {
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
const result = await model3DGenerationService.getTask(params.taskId, {
providerId: params.providerId,
});
const status = String(result.data?.status || "unknown");
const progress = result.data?.progress ?? null;
if (!result.success || !result.data) {
const error = result.error || "3D 模型任务查询失败";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
if (
status === "failed" ||
status === "error" ||
status === "cancelled" ||
status === "canceled"
) {
const error = result.data.errorMessage || "3D 模型任务失败";
const refundedPoints = Number(result.data.refundedPoints || 0);
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
errorCode: result.data.errorCode ?? null,
refundedPoints,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
errorCode: result.data.errorCode ?? null,
refundedPoints,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(
refundedPoints > 0
? `${error},已退回 ${refundedPoints} 积分`
: error
);
return;
}
if (status === "success") {
const modelUrl = String(result.data.modelUrl || "").trim();
const previewImageUrl =
result.data.previewImageUrl ||
params.previewImageUrl ||
params.sourceImageUrl;
const responseAssetPayload =
result.data.assetPayload &&
typeof result.data.assetPayload === "object" &&
!Array.isArray(result.data.assetPayload)
? (result.data.assetPayload as Record<string, unknown>)
: null;
const nextAssetPayload = {
...(responseAssetPayload || {}),
providerId:
params.providerId ||
(typeof responseAssetPayload?.providerId === "string"
? responseAssetPayload.providerId
: null),
providerName:
params.providerName ||
(typeof responseAssetPayload?.providerName === "string"
? responseAssetPayload.providerName
: null),
sourceImageUrl: params.sourceImageUrl,
};
if (!modelUrl) {
const error = "3D 模型已完成,但没有返回模型文件";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: previewImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
src: modelUrl,
previewImage: previewImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
format: inferModelFormat(modelUrl),
status: "completed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress: 100,
output: result.data.output || null,
assetPayload: nextAssetPayload,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "success",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: 100,
modelUrl,
previewImageUrl,
error: null,
assetPayload: nextAssetPayload,
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("3D 模型已生成到画布");
return;
}
if (isTerminal3DFailureStatus(status)) {
const error = result.data.errorMessage || "3D 模型生成失败";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "generating",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
},
},
});
persistCanvasStateSnapshot();
}
const error = "3D 模型任务轮询超时,请稍后重试";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
},
[persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode]
);
const pollCanvasWorld3DTask = useCallback(
async (params: {
nodeId: string;
taskId: string;
providerId?: string | null;
providerName?: string | null;
promptText: string;
modelVersion: string;
sourceImageUrl: string;
}) => {
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
const result = await worldLabsGenerationService.getTask(params.taskId, {
providerId: params.providerId,
});
const status = String(result.data?.status || "unknown");
const progress = result.data?.progress ?? null;
if (!result.success || !result.data) {
const error = result.error || "3D 世界任务查询失败";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
if (status === "success") {
const assetPayload = (result.data.assetPayload || {}) as Record<string, any>;
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | null | undefined
);
const returnedPanoUrl =
typeof assetPayload.panoUrl === "string" && assetPayload.panoUrl.trim()
? assetPayload.panoUrl.trim()
: null;
const returnedThumbnailUrl =
typeof assetPayload.thumbnailUrl === "string" && assetPayload.thumbnailUrl.trim()
? assetPayload.thumbnailUrl.trim()
: null;
const returnedPreviewImageUrl =
typeof result.data.previewImageUrl === "string" &&
result.data.previewImageUrl.trim()
? result.data.previewImageUrl.trim()
: null;
const rawReturnedMeshUrl =
result.data.modelUrl ||
(typeof assetPayload.meshUrl === "string" && assetPayload.meshUrl.trim()
? assetPayload.meshUrl.trim()
: null);
const returnedMeshUrl = isArchivedWorldLabsAssetUrl(rawReturnedMeshUrl)
? rawReturnedMeshUrl
: null;
const hasRenderableWorldAsset = Boolean(
splatUrl ||
returnedPanoUrl ||
returnedThumbnailUrl ||
returnedPreviewImageUrl ||
returnedMeshUrl
);
if (!hasRenderableWorldAsset) {
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "generating",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress: 95,
assetPayload,
},
},
});
persistCanvasStateSnapshot();
continue;
}
const imageUrl =
returnedPanoUrl ||
returnedThumbnailUrl ||
returnedPreviewImageUrl ||
params.sourceImageUrl;
const meshUrl = returnedMeshUrl;
const worldUrl =
typeof assetPayload.worldUrl === "string" ? assetPayload.worldUrl : null;
const previewImageUrl = result.data.previewImageUrl || imageUrl;
updateNode(params.nodeId, {
data: {
worldUrl: worldUrl || undefined,
imageUrl: imageUrl || undefined,
meshUrl: meshUrl || undefined,
splatUrl: splatUrl || undefined,
previewImage: previewImageUrl || undefined,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "completed",
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
firstPerson: false,
hotspots: [],
semanticsMetadata:
assetPayload.semanticsMetadata &&
typeof assetPayload.semanticsMetadata === "object"
? assetPayload.semanticsMetadata
: undefined,
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress: 100,
assetPayload: {
...assetPayload,
providerId: params.providerId || assetPayload.providerId || null,
providerName:
params.providerName || assetPayload.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "success",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: 100,
modelUrl: meshUrl,
previewImageUrl,
error: null,
assetPayload: {
...assetPayload,
providerId: params.providerId || assetPayload.providerId || null,
providerName: params.providerName || assetPayload.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("3D 世界已生成到画布");
return;
}
if (isTerminal3DFailureStatus(status)) {
const error = result.data.errorMessage || "3D 世界生成失败";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "generating",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress,
},
},
});
persistCanvasStateSnapshot();
}
const error = "3D 世界任务轮询超时,请稍后重试";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
},
[persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode]
);
const handleGenerateCanvas3DModel = useCallback(async (requestedModelVersion?: string) => {
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("请选择一张可访问的 URL 图片");
return;
}
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const promptText = (data.prompt || displayTitle || "图生 3D 模型").trim();
const modelVersion =
requestedModelVersion || DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard";
const sourceImageUrl = data.src;
const newNodeId = addNode({
type: "model3d",
position: {
x: node.position.x + node.size.width + 24,
y: node.position.y,
},
size: { width: Math.max(360, node.size.width), height: 300 },
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "generating",
metadata: {
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
},
},
connections: [node.id],
});
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const createResult = await model3DGenerationService.createTask({
taskType: "image_to_model",
imageUrl: sourceImageUrl,
imageType: inferTripoImageType(sourceImageUrl),
prompt: promptText,
modelVersion,
faceLimit: 120000,
texture: true,
pbr: modelFeatureVisibility.pbr !== false,
autoSize: false,
quad: false,
generateParts: false,
smartLowPoly: false,
geometryQuality: "standard",
textureQuality: "standard",
textureAlignment: "original_image",
orientation: "default",
});
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建图生 3D 模型任务失败");
}
const providerId = createResult.data.providerId || null;
const providerName = createResult.data.providerName || null;
updateNode(newNodeId, {
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "generating",
metadata: {
taskId: createResult.data.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
providerId,
providerName,
},
},
});
await persistCanvas3DAsset({
taskId: createResult.data.taskId,
provider: "tripo-3d",
status: createResult.data.status || "running",
mode: "image",
prompt: promptText,
modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: sourceImageUrl,
error: null,
assetPayload: { providerId, providerName, sourceImageUrl },
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("已在画布创建 3D 模型任务");
void pollCanvasModel3DTask({
nodeId: newNodeId,
taskId: createResult.data.taskId,
providerId,
providerName,
promptText,
modelVersion,
sourceImageUrl,
previewImageUrl: sourceImageUrl,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "创建图生 3D 模型任务失败";
updateNode(newNodeId, {
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "failed",
metadata: {
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
error: message,
},
},
});
persistCanvasStateSnapshot();
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
addNode,
data.prompt,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.width,
persistCanvas3DAsset,
persistCanvasStateSnapshot,
pollCanvasModel3DTask,
projectId,
selectNode,
updateNode,
]);
const handleGenerateCanvas3DWorld = useCallback(async (requestedModelVersion?: string) => {
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("请选择一张可访问的 URL 图片");
return;
}
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const promptText = (data.prompt || displayTitle || "图生 3D 世界").trim();
const modelVersion =
requestedModelVersion || DEFAULT_CANVAS_WORLD_MODEL || "world-standard";
const sourceImageUrl = data.src;
const newNodeId = addNode({
type: "world3d",
position: {
x: node.position.x + node.size.width + 24,
y: node.position.y + 24,
},
size: { width: Math.max(420, node.size.width), height: 320 },
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "generating",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
generationStartedAt: new Date().toISOString(),
},
},
connections: [node.id],
});
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const createResult = await worldLabsGenerationService.createTask({
mode: "image",
sourceUrl: sourceImageUrl,
prompt: promptText || undefined,
displayName: displayTitle || promptText || "画布图生世界",
model: modelVersion,
disableRecaption: false,
makePublic: false,
});
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建图生 3D 世界任务失败");
}
const createAssetPayload = {
...(createResult.data.assetPayload || {}),
sourceImageUrl,
};
updateNode(newNodeId, {
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "generating",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
taskId: createResult.data.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
generationStartedAt: new Date().toISOString(),
assetPayload: createAssetPayload,
},
},
});
await persistCanvas3DAsset({
taskId: createResult.data.taskId,
provider: "worldlabs-3d",
status: createResult.data.status || "running",
mode: "world-image",
prompt: promptText,
modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: sourceImageUrl,
error: null,
assetPayload: createAssetPayload,
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("已在画布创建 3D 世界任务");
void pollCanvasWorld3DTask({
nodeId: newNodeId,
taskId: createResult.data.taskId,
providerId: createAssetPayload.providerId || null,
providerName: createAssetPayload.providerName || null,
promptText,
modelVersion,
sourceImageUrl,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "创建图生 3D 世界任务失败";
updateNode(newNodeId, {
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "failed",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
error: message,
},
},
});
persistCanvasStateSnapshot();
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
addNode,
data.prompt,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.width,
persistCanvas3DAsset,
persistCanvasStateSnapshot,
pollCanvasWorld3DTask,
projectId,
selectNode,
updateNode,
]);
// 基于当前图片+新提示词重绘
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);
setIsCreatingDetachedResultNode(true);
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,
billingFeature: "repaint",
});
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 {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => {
if (!data.src) return;
if (isDownloadingOriginalImage) {
toast.info("原图正在下载中");
return;
}
const controller = new AbortController();
downloadAbortControllerRef.current = controller;
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
await downloadCanvasImageAsPng({
url: data.src,
name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("原图下载成功");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");
} else {
console.error("下载失败:", error);
toast.error(
error instanceof Error
? `${error.message},源图可能已失效,请稍后重试或重新生成图片`
: "图片源文件暂时不可访问,请稍后重试或重新生成图片"
);
}
} finally {
if (downloadAbortControllerRef.current === controller) {
downloadAbortControllerRef.current = null;
}
setDownloadProgress(null);
}
};
const handleCancelDownload = () => {
downloadAbortControllerRef.current?.abort();
setDownloadProgress(null);
};
const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => {
if (boxCutoutMetrics) {
return {
left: boxCutoutMetrics.offsetX,
top: boxCutoutMetrics.offsetY,
width: boxCutoutMetrics.renderedWidth,
height: boxCutoutMetrics.renderedHeight,
};
}
return { inset: 0 };
}, [boxCutoutMetrics]);
const renderImageAdjustmentOverlays = useCallback(() => {
return (
<ImageNodeAdjustOverlays
adjustments={activeImageAdjustments}
hasActiveAdjustments={hasActiveImageAdjustments}
style={imageAdjustOverlayStyle}
/>
);
}, [
activeImageAdjustments,
hasActiveImageAdjustments,
imageAdjustOverlayStyle,
]);
const cropPanelPosition = getCropPanelPosition();
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
const toolbarPosition = getToolbarPosition();
const aiWashQuickPanelPosition = getAiWashQuickPanelPosition();
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
const flipRotatePanelPosition = getFlipRotatePanelPosition();
const anglePanelPosition = getAnglePanelPosition();
const boxCutoutHintText = boxCutoutSelection
? "确认后将仅抠出框选区域"
: isBoxCutoutDrawing
? "松开完成框选"
: "在图片中拖拽矩形框";
return (
<>
{/* 选中时在图片上方显示标题 */}
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
<ImageNodeSelectedTitle
displayTitle={displayTitle}
draftTitle={draftTitle}
isEditingTitle={isEditingTitle}
onCancelEditing={() => setIsEditingTitle(false)}
onDraftTitleChange={setDraftTitle}
onSaveTitle={handleSaveTitle}
onStartEditing={() => setIsEditingTitle(true)}
resolutionLabel={resolutionLabel}
style={selectedTitleStyle}
/>
)}
<div
className={cn(
"relative h-full w-full rounded-none border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_16px_34px_rgba(22,24,29,0.08)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
localEditMode ? "overflow-visible" : "overflow-hidden",
isRecentlyGenerated &&
"border-[#7c9cff] shadow-[0_0_0_1px_rgba(124,156,255,0.42),0_0_0_8px_rgba(124,156,255,0.12),0_18px_42px_rgba(91,118,254,0.22)]",
isSelected
? "border-[#5b76fe] ring-2 ring-[#5b76fe]/14 shadow-[0_0_0_1px_rgba(91,118,254,0.14),0_20px_38px_rgba(91,118,254,0.12)]"
: "hover:border-[#cfd9ea]"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
ref={containerRef}
>
{isRecentlyGenerated ? (
<div className="pointer-events-none absolute inset-0 z-[3] rounded-[inherit]">
<div
className="absolute inset-0 rounded-[inherit] border border-[#8fb0ff]/75"
style={{ animation: "canvas-generated-pulse 1.35s ease-out infinite" }}
/>
<div className="absolute inset-1 rounded-[inherit] border border-white/70 shadow-[0_0_22px_rgba(124,156,255,0.34)] dark:border-white/35" />
</div>
) : null}
<div className="relative h-full w-full" style={scaledContentStyle}>
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? (
<div className="relative h-full w-full">
{!shouldRequestDisplayImage ? (
<ImageNodeDeferredLoadState />
) : isCompareMode && hasCompareSource ? (
<div
className="relative h-full w-full cursor-col-resize select-none overflow-hidden bg-slate-50 dark:bg-zinc-900"
data-canvas-no-drag="true"
onPointerDown={(event) => {
event.stopPropagation();
updateCompareSplitByClientX(event.clientX);
}}
onPointerMove={(event) => {
updateCompareSplitByClientX(event.clientX);
}}
style={{ touchAction: "none" }}
>
<div
className="absolute overflow-hidden bg-slate-50 dark:bg-zinc-900"
ref={compareFrameRef}
style={compareImageFrameStyle}
>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - compareSplitPercent}% 0 0)`,
}}
>
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img
alt={displayTitle}
className="absolute inset-0 h-full w-full object-cover"
decoding="async"
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
onLoad={handleDisplayImageLoad}
ref={displayImageRef}
src={displayImageSrc}
style={activeImageAdjustmentsStyle}
/>
<ImageNodeAdjustOverlays
adjustments={activeImageAdjustments}
hasActiveAdjustments={hasActiveImageAdjustments}
style={{ inset: 0 }}
/>
</div>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 0 0 ${compareSplitPercent}%)`,
}}
>
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img
alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-cover"
decoding="async"
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = compareSource;
}
}}
src={displayCompareSrc}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 z-30 w-px bg-white/95 shadow-[0_0_0_1px_rgba(15,23,42,0.18),0_0_6px_rgba(255,255,255,0.28)]"
style={{
left: `${compareSplitPercent}%`,
transform: "translateX(-50%)",
}}
/>
<div
aria-hidden="true"
className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 z-40 flex h-7 w-7 items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.26)]"
style={{
left: `clamp(14px, ${compareSplitPercent}%, calc(100% - 14px))`,
}}
>
&lt;&gt;
</div>
</div>
<button
className="-translate-x-1/2 absolute top-2 left-1/2 z-50 rounded-full border border-[#dbe2ee] bg-white px-2.5 py-1 text-[10px] font-medium text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] transition hover:bg-[#f7f9ff] hover:text-[#16181d]"
data-canvas-no-drag="true"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsCompareMode(false);
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
type="button"
>
取消对比
</button>
</div>
) : (
<>
<img
alt={displayTitle}
className={cn(
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
boxCutoutMode || localEditMode
? "cursor-crosshair"
: isThisNodePicking
? "cursor-crosshair"
: "cursor-pointer"
)}
decoding="async"
draggable={false}
loading="lazy"
onClick={
isThisNodePicking && !boxCutoutMode
? handlePickerClick
: undefined
}
onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
e.stopPropagation();
setFullscreenPreviewOpen(true);
}
}}
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
onLoad={handleDisplayImageLoad}
ref={displayImageRef}
src={displayImageSrc}
style={activeImageAdjustmentsStyle}
/>
{renderImageAdjustmentOverlays()}
{shouldRenderDetailOverlay ? (
detailUiDslDocument ? (
<DetailUiDomRenderer
document={detailUiDslDocument}
imageMetrics={boxCutoutMetrics}
/>
) : (
<DetailSurfaceOverlayRenderer
imageMetrics={boxCutoutMetrics}
overlay={detailUiScreenOverlay}
subjectAnalysis={detailSubjectAnalysis}
/>
)
) : null}
{canvasAgentAnnotations.length > 0 &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
!isThisNodePicking ? (
<div
className="pointer-events-none absolute inset-0 z-[132]"
data-canvas-agent-annotations="true"
>
{boxCutoutMetrics
? canvasAgentAnnotations.map((annotation) => {
const boundsStyle = getCanvasAnnotationBoundsStyle(
annotation,
boxCutoutMetrics
);
if (!boundsStyle) return null;
const color = annotation.color || "#f59e0b";
return (
<div
className="absolute rounded-[3px] border-2 border-dashed bg-transparent"
key={`agent-annotation-bounds-${annotation.id}`}
style={{
...boundsStyle,
borderColor: color,
boxShadow: `0 0 0 1px ${color}33`,
}}
/>
);
})
: null}
<div className="absolute right-2 bottom-2 flex max-w-[calc(100%-1rem)] flex-col items-end gap-1">
<div
className="inline-flex max-w-full items-center gap-1 rounded-full border border-amber-200 bg-white/95 px-2 py-1 font-medium text-[11px] text-amber-700 shadow-[0_8px_20px_rgba(15,23,42,0.14)] dark:border-amber-400/30 dark:bg-zinc-950/88 dark:text-amber-200"
title={visibleCanvasAgentAnnotations
.map((annotation) => annotation.text)
.filter(Boolean)
.join(" / ")}
>
<MessageSquarePlus className="h-3 w-3 shrink-0" />
<span className="shrink-0">批注</span>
<span className="shrink-0">{canvasAgentAnnotations.length}</span>
</div>
{isSelected
? visibleCanvasAgentAnnotations
.filter((annotation) => annotation.text)
.map((annotation) => (
<div
className="max-w-[220px] truncate rounded-md border border-amber-200 bg-white/95 px-2 py-1 text-right text-[11px] text-slate-700 shadow-[0_8px_20px_rgba(15,23,42,0.12)] dark:border-amber-400/25 dark:bg-zinc-950/88 dark:text-slate-100"
key={`agent-annotation-text-${annotation.id}`}
title={annotation.text}
>
{annotation.text}
</div>
))
: null}
</div>
</div>
) : null}
</>
)}
{cropPanelOpen &&
!isThisNodePicking &&
!isCompareMode &&
boxCutoutMetrics &&
cropRectStyle &&
cropRect && (
<div
className="absolute z-[148]"
data-canvas-no-drag="true"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={handleCropPointerCancel}
onPointerDown={handleCropPointerDown}
onPointerMove={handleCropPointerMove}
onPointerUp={handleCropPointerUp}
ref={cropOverlayRef}
style={{
left: boxCutoutMetrics.offsetX,
top: boxCutoutMetrics.offsetY,
width: boxCutoutMetrics.renderedWidth,
height: boxCutoutMetrics.renderedHeight,
}}
>
<div
className="pointer-events-none absolute inset-x-0 top-0 bg-[rgba(26,28,31,0.48)]"
style={{ height: Math.max(cropRectStyle.top - boxCutoutMetrics.offsetY, 0) }}
/>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 bg-[rgba(26,28,31,0.48)]"
style={{
height: Math.max(
boxCutoutMetrics.offsetY +
boxCutoutMetrics.renderedHeight -
(cropRectStyle.top + cropRectStyle.height),
0
),
}}
/>
<div
className="pointer-events-none absolute left-0 bg-[rgba(26,28,31,0.48)]"
style={{
top: cropRectStyle.top - boxCutoutMetrics.offsetY,
width: Math.max(cropRectStyle.left - boxCutoutMetrics.offsetX, 0),
height: cropRectStyle.height,
}}
/>
<div
className="pointer-events-none absolute right-0 bg-[rgba(26,28,31,0.48)]"
style={{
top: cropRectStyle.top - boxCutoutMetrics.offsetY,
width: Math.max(
boxCutoutMetrics.offsetX +
boxCutoutMetrics.renderedWidth -
(cropRectStyle.left + cropRectStyle.width),
0
),
height: cropRectStyle.height,
}}
/>
<div
className="absolute border-2 border-white/90 shadow-[0_0_0_1px_rgba(255,255,255,0.18)]"
data-crop-handle="move"
style={{
left: cropRectStyle.left - boxCutoutMetrics.offsetX,
top: cropRectStyle.top - boxCutoutMetrics.offsetY,
width: cropRectStyle.width,
height: cropRectStyle.height,
cursor: cropInteraction ? "grabbing" : "grab",
}}
>
<div className="pointer-events-none absolute top-1/3 left-0 h-px w-full bg-white/45" />
<div className="pointer-events-none absolute top-2/3 left-0 h-px w-full bg-white/45" />
<div className="pointer-events-none absolute top-0 left-1/3 h-full w-px bg-white/45" />
<div className="pointer-events-none absolute top-0 left-2/3 h-full w-px bg-white/45" />
{(
[
{ handle: "nw", className: "-left-3 -top-3 cursor-nwse-resize" },
{ handle: "ne", className: "-right-3 -top-3 cursor-nesw-resize" },
{ handle: "sw", className: "-left-3 -bottom-3 cursor-nesw-resize" },
{ handle: "se", className: "-right-3 -bottom-3 cursor-nwse-resize" },
{ handle: "n", className: "left-1/2 -top-3 -translate-x-1/2 cursor-ns-resize" },
{ handle: "s", className: "left-1/2 -bottom-3 -translate-x-1/2 cursor-ns-resize" },
{ handle: "w", className: "-left-3 top-1/2 -translate-y-1/2 cursor-ew-resize" },
{ handle: "e", className: "-right-3 top-1/2 -translate-y-1/2 cursor-ew-resize" },
] as const
).map((handleItem) => (
<button
className={cn(
"absolute h-6 w-6 rounded-full border border-[#d9d9d9] bg-white shadow-[0_2px_10px_rgba(0,0,0,0.12)]",
handleItem.className
)}
data-crop-handle={handleItem.handle}
key={handleItem.handle}
type="button"
/>
))}
</div>
</div>
)}
{boxCutoutMode && !isThisNodePicking && !isCompareMode && (
<>
<div
className="absolute inset-0 z-[140] cursor-crosshair"
data-canvas-no-drag="true"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={handleBoxCutoutPointerCancel}
onPointerDown={handleBoxCutoutPointerDown}
onPointerMove={handleBoxCutoutPointerMove}
onPointerUp={handleBoxCutoutPointerUp}
/>
{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" />
)}
</>
)}
{localEditMode && !isThisNodePicking && !isCompareMode && (
<>
<div
className="absolute inset-0 z-[150] cursor-crosshair"
data-canvas-no-drag="true"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={handleLocalEditPointerCancel}
onPointerDown={handleLocalEditPointerDown}
onPointerMove={handleLocalEditPointerMove}
onPointerUp={handleLocalEditPointerUp}
/>
{boxCutoutMetrics ? (
<div
className="pointer-events-none absolute z-[151] rounded-[2px] border border-dashed border-emerald-300/36 bg-emerald-300/5"
style={{
left: boxCutoutMetrics.offsetX,
top: boxCutoutMetrics.offsetY,
width: boxCutoutMetrics.renderedWidth,
height: boxCutoutMetrics.renderedHeight,
}}
/>
) : null}
{localEditScaleTargetOverlays.map(({ region, sourceStyle, style }) => {
const sourceLeft = Number(sourceStyle.left || 0);
const sourceTop = Number(sourceStyle.top || 0);
const sourceWidth = Number(sourceStyle.width || 0);
const sourceHeight = Number(sourceStyle.height || 0);
const targetLeft = Number(style.left || 0);
const targetTop = Number(style.top || 0);
const targetWidth = Number(style.width || 0);
const targetHeight = Number(style.height || 0);
const markerId = `canvas-scale-arrow-${node.id}-${region.id}`;
const connectorPoints = [
{
key: "nw",
source: { x: sourceLeft, y: sourceTop },
target: { x: targetLeft, y: targetTop },
},
{
key: "ne",
source: { x: sourceLeft + sourceWidth, y: sourceTop },
target: { x: targetLeft + targetWidth, y: targetTop },
},
{
key: "sw",
source: { x: sourceLeft, y: sourceTop + sourceHeight },
target: { x: targetLeft, y: targetTop + targetHeight },
},
{
key: "se",
source: {
x: sourceLeft + sourceWidth,
y: sourceTop + sourceHeight,
},
target: {
x: targetLeft + targetWidth,
y: targetTop + targetHeight,
},
},
];
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[161] h-full w-full overflow-visible"
key={`scale-connector-${region.id}`}
>
<defs>
<marker
id={markerId}
markerHeight="8"
markerUnits="strokeWidth"
markerWidth="8"
orient="auto"
refX="7"
refY="4"
>
<path d="M0,0 L8,4 L0,8 Z" fill="#67e8f9" />
</marker>
</defs>
{connectorPoints.map((point) => (
<line
key={`line-${point.key}`}
markerEnd={`url(#${markerId})`}
stroke="#67e8f9"
strokeDasharray="6 5"
strokeLinecap="round"
strokeWidth="2"
x1={point.source.x}
x2={point.target.x}
y1={point.source.y}
y2={point.target.y}
/>
))}
{connectorPoints.map((point) => (
<circle
cx={point.target.x}
cy={point.target.y}
fill="#ecfeff"
key={`dot-${point.key}`}
r="3.25"
stroke="#06b6d4"
strokeWidth="1.5"
/>
))}
</svg>
);
})}
{localEditScaleTargetOverlays.map(
({ region, style, targetRect }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const active =
localEditRegionInteraction?.regionId === region.id &&
localEditRegionInteraction.target === "scaleTarget";
return (
<div
className={cn(
"nodrag nopan nowheel pointer-events-auto absolute z-[162] rounded-none border-[1.5px] border-dashed border-cyan-300 bg-cyan-300/5 shadow-[0_0_0_1px_rgba(8,145,178,0.16)]",
active ? "cursor-grabbing" : "cursor-grab"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
key={`scale-target-${region.id}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={(event) =>
handleLocalEditRegionPointerCancel(event, region.id)
}
onPointerDown={(event) =>
handleLocalEditRegionPointerDown(
event,
region,
"scaleTarget",
targetRect
)
}
onPointerMove={(event) =>
handleLocalEditRegionPointerMove(event, region.id)
}
onPointerUp={(event) =>
handleLocalEditRegionPointerUp(event, region.id)
}
style={{
left,
top,
width: Math.max(width, 1),
height: Math.max(height, 1),
}}
title="目标框"
>
<div className="-top-6 left-0 pointer-events-none absolute rounded-full bg-cyan-500/90 px-2 py-0.5 text-[11px] font-semibold text-white shadow-sm">
目标框
</div>
{(
[
{
handle: "nw",
className: "-left-[5px] -top-[5px] cursor-nwse-resize",
},
{
handle: "ne",
className: "-right-[5px] -top-[5px] cursor-nesw-resize",
},
{
handle: "sw",
className: "-left-[5px] -bottom-[5px] cursor-nesw-resize",
},
{
handle: "se",
className: "-right-[5px] -bottom-[5px] cursor-nwse-resize",
},
] as const
).map((item) => (
<button
aria-label={`调整区域 ${region.index} 的目标框`}
className={cn(
"nodrag nopan nowheel absolute h-[10px] w-[10px] rounded-none border-[1.5px] border-cyan-300 bg-white shadow-none",
item.className
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
data-local-edit-region-handle={item.handle}
key={item.handle}
type="button"
/>
))}
</div>
);
}
)}
{localEditRegionOverlays.map(({ region, style }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const isAwaitingScaleTarget =
localEditOperationMode === "scale_adjust" && !region.targetRect;
const active =
localEditRegionInteraction?.regionId === region.id &&
localEditRegionInteraction.target === "source";
return (
<div
className={cn(
"nodrag nopan nowheel absolute z-[160] rounded-none border-[1.5px] bg-transparent shadow-none",
isAwaitingScaleTarget
? "pointer-events-none cursor-crosshair"
: "pointer-events-auto",
active ? "cursor-grabbing" : "cursor-grab"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
key={region.id}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={(event) =>
handleLocalEditRegionPointerCancel(event, region.id)
}
onPointerDown={(event) =>
handleLocalEditRegionPointerDown(event, region)
}
onPointerMove={(event) =>
handleLocalEditRegionPointerMove(event, region.id)
}
onPointerUp={(event) =>
handleLocalEditRegionPointerUp(event, region.id)
}
style={{
left,
top,
width: Math.max(width, 1),
height: Math.max(height, 1),
borderColor: region.color,
}}
>
{!isAwaitingScaleTarget ? (
[
{
handle: "nw",
className: "-left-[5px] -top-[5px] cursor-nwse-resize",
},
{
handle: "ne",
className: "-right-[5px] -top-[5px] cursor-nesw-resize",
},
{
handle: "sw",
className: "-left-[5px] -bottom-[5px] cursor-nesw-resize",
},
{
handle: "se",
className: "-right-[5px] -bottom-[5px] cursor-nwse-resize",
},
] as const
).map((item) => (
<button
aria-label={`调整区域 ${region.index}`}
className={cn(
"nodrag nopan nowheel absolute h-[10px] w-[10px] rounded-none border-[1.5px] bg-white shadow-none",
item.className
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
data-local-edit-region-handle={item.handle}
key={item.handle}
style={{ borderColor: region.color }}
type="button"
/>
)) : null}
</div>
);
})}
{(localEditOperationMode as CanvasLocalEditOperationMode) !== "scale_adjust" &&
typeof document !== "undefined" &&
localEditPromptPortalFrame
? createPortal(
<div
className="pointer-events-none fixed z-[9998] overflow-visible"
data-local-edit-control="true"
style={{
left: localEditPromptPortalFrame.left,
top: localEditPromptPortalFrame.top,
width: localEditPromptPortalFrame.width,
height: localEditPromptPortalFrame.height,
}}
>
{localEditPromptViewportOverlays.map(
({ region, isEditing, isAwaitingScaleTarget, style }) => (
<div
className={cn(
"nodrag nopan nowheel absolute z-[170] flex flex-col items-stretch gap-1.5",
isAwaitingScaleTarget ? "pointer-events-none" : "pointer-events-auto"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
key={`local-edit-prompt-${region.id}`}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={style}
>
<div className="flex items-center gap-1.5">
{isEditing ? (
<input
autoFocus={editingLocalEditRegionId === region.id}
className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium leading-5 text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18"
data-canvas-no-drag="true"
data-local-edit-control="true"
autoComplete="off"
enterKeyHint="done"
inputMode="text"
lang="zh-CN"
defaultValue={
localEditPromptDrafts[region.id] ??
localEditPromptDraftRefs.current[region.id] ??
region.prompt
}
onBlur={(event) => {
const nextPrompt =
localEditPromptDraftRefs.current[region.id] ??
event.currentTarget.value;
event.currentTarget.value = nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
}}
onClick={(event) => {
event.stopPropagation();
}}
onCompositionEnd={(event) => {
finishLocalEditPromptComposition(
region.id,
event.currentTarget,
event.data
);
}}
onCompositionStart={(event) => {
localEditPromptCompositionStartRefs.current[
region.id
] = {
end:
event.currentTarget.selectionEnd ??
event.currentTarget.value.length,
start:
event.currentTarget.selectionStart ??
event.currentTarget.value.length,
value:
localEditPromptDraftRefs.current[region.id] ??
event.currentTarget.value,
};
}}
onFocus={(event) => {
event.stopPropagation();
setEditingLocalEditRegionId((current) =>
current === region.id ? current : region.id
);
}}
onChange={(event) => {
updateLocalEditPromptDraftRef(
region.id,
event.currentTarget.value
);
}}
onKeyDown={(event) => {
const isComposing =
event.nativeEvent.isComposing ||
event.key === "Process";
if (isComposing) {
return;
}
if (
applyLocalEditPromptKeyEdit(
region.id,
event.currentTarget,
event
)
) {
return;
}
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
event.stopPropagation();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
spellCheck={false}
type="text"
/>
) : (
<button
className="nodrag nopan nowheel h-9 min-w-0 flex-1 truncate rounded-full border border-slate-200 bg-white/95 px-3 text-left text-[12px] font-semibold text-slate-900 shadow-[0_10px_24px_rgba(15,23,42,0.14)] hover:bg-slate-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-slate-900"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
}}
title={region.prompt}
type="button"
>
{`${region.index}. ${region.prompt}`}
</button>
)}
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden"
data-canvas-no-drag="true"
data-local-edit-control="true"
id={`local-edit-reference-upload-${region.id}`}
multiple
onChange={(event) => {
void handleLocalEditReferenceUpload(
region.id,
event.currentTarget.files
);
event.currentTarget.value = "";
}}
type="file"
/>
<button
className={cn(
"nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
disabled={
uploadingLocalEditReferenceRegionId === region.id ||
(region.referenceImages?.length || 0) >=
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION
}
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
document
.getElementById(
`local-edit-reference-upload-${region.id}`
)
?.click();
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
title="添加区域参考图"
type="button"
>
{uploadingLocalEditReferenceRegionId === region.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
</button>
<button
className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-500 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-red-500 hover:text-white dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)]"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
deleteLocalEditRegion(region.id);
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
deleteLocalEditRegion(region.id);
}}
title="删除区域"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{region.referenceImages?.length ? (
<div className="flex max-w-full items-center gap-1 overflow-hidden rounded-2xl border border-slate-200 bg-white/92 px-1.5 py-1 shadow-[0_8px_20px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-slate-950/80">
<span className="shrink-0 px-1 text-[10px] font-semibold text-slate-500 dark:text-white/52">
参考
</span>
{region.referenceImages.map((reference) => (
<div
className="group relative h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-slate-100 dark:border-white/12 dark:bg-slate-800"
key={reference.id}
title={reference.name || "区域参考图"}
>
<img
alt={reference.name || "区域参考图"}
className="h-full w-full object-cover"
draggable={false}
src={reference.url}
/>
<button
className="absolute inset-0 hidden items-center justify-center bg-black/52 text-white group-hover:flex"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
removeLocalEditReferenceImage(
region.id,
reference.id
);
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
removeLocalEditReferenceImage(
region.id,
reference.id
);
}}
title="移除参考图"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
</div>
)
)}
</div>,
document.body
)
: null}
{localEditDraftRectStyle ? (
<div
className={cn(
"pointer-events-none absolute z-[162] rounded-none border-[1.5px] bg-transparent shadow-none",
localEditDrawingTargetRegionId
? "border-dashed border-cyan-300"
: "border-[#27a8ff]"
)}
style={{
left: localEditDraftRectStyle.left,
top: localEditDraftRectStyle.top,
width: Math.max(Number(localEditDraftRectStyle.width || 0), 1),
height: Math.max(Number(localEditDraftRectStyle.height || 0), 1),
}}
/>
) : null}
</>
)}
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
{isThisNodePicking && currentPickerClickStyle && (
<div
className="pointer-events-none absolute"
style={{
left: currentPickerClickStyle.left,
top: currentPickerClickStyle.top,
transform: "translate(-50%, -50%)",
}}
>
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-dashed" />
</div>
)}
{/* 🆕 拾取模式:状态栏 */}
{isThisNodePicking && (
<div className="-translate-x-1/2 absolute bottom-2 left-1/2 flex items-center gap-2 rounded-full bg-white px-3 py-1.5 shadow-lg">
<Crosshair className="h-4 w-4 text-orange-500" />
<span className="font-medium text-xs">
{isPickerLoading ? "正在识别..." : "点击标注物品"}
</span>
<Button
className="h-6 w-6 rounded-full hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
handleCancelPicking();
}}
size="icon"
variant="ghost"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{hasCompareSource &&
!isThisNodePicking &&
!isCompareMode &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
isSelected &&
!isMultiSelected && (
<button
aria-label={imageCompareItem.label}
className="-translate-y-1/2 pointer-events-auto absolute top-1/2 z-[220] inline-flex 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"
onClick={(event) => {
event.stopPropagation();
setIsCompareMode((prev) => !prev);
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
style={{
width: `${compareButtonHitSize}px`,
height: `${compareButtonHitSize}px`,
right: `${compareButtonInset}px`,
}}
title={imageCompareItem.label}
type="button"
>
<span
className="inline-flex items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] transition-colors hover:bg-[#f7f9ff] hover:text-[#16181d]"
style={{
width: `${compareButtonVisualSize}px`,
height: `${compareButtonVisualSize}px`,
}}
>
<CanvasToolbarIcon
fallback={Scan}
item={imageCompareItem}
style={{
width: `${compareButtonIconSize}px`,
height: `${compareButtonIconSize}px`,
}}
/>
</span>
</button>
)}
</div>
) : (
<ImageNodeEmptyState
detailProgressLabel={detailProgressLabel}
fileInputRef={fileInputRef}
generationProgress={generationProgress}
onImageUpload={handleImageUpload}
onSelectFile={handleSelectFile}
status={data.status}
viewportScale={viewportZoom}
/>
)}
{/* 处理中遮罩 */}
{isProcessing &&
!isSubmittingLocalEdit &&
!isCreatingDetachedResultNode && (
<ImageNodeProcessingOverlay
detailProgressLabel={detailProgressLabel || undefined}
generationProgress={generationProgress}
viewportScale={viewportZoom}
/>
)}
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{/* 悬浮时的更多操作按钮 (右上角) */}
{isHovered &&
data.src &&
!isProcessing &&
!isSelected &&
hasHoverImageMoreMenu ? (
<div className="absolute top-2 right-2 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-8 w-8 rounded-full border border-[#dbe2ee] bg-white text-[#5d687b] shadow-[0_0_0_1px_rgba(219,226,238,0.3)] hover:bg-[#f7f9ff] hover:text-[#16181d]"
size="icon"
title={imageNodeMoreItem.label}
variant="secondary"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={imageNodeMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{imageRemoveBackgroundEnabled ? (
<DropdownMenuItem onClick={() => handleAction("rmbg")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Scissors}
item={imageRemoveBackgroundItem}
/>
<span>{imageRemoveBackgroundItem.label}</span>
<ImageActionCreditBadge
label={removeBackgroundCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{imageRemoveWatermarkEnabled ? (
<DropdownMenuItem onClick={() => handleAction("watermark")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
<span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge
label={removeWatermarkCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{imageEnhanceEnabled ? (
<DropdownMenuItem onClick={() => handleAction("enhance")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Sparkles}
item={imageEnhanceItem}
/>
{imageEnhanceItem.label}
<ImageActionCreditBadge label={enhanceCostShortLabel} />
</DropdownMenuItem>
) : null}
{imageNoteEnabled ? (
<DropdownMenuItem onClick={() => handleAction("note")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</DropdownMenuItem>
) : null}
{hasCanvasAgentAnnotations ? (
<DropdownMenuItem onClick={requestCanvasAgentAnnotationEdit}>
<Wand2 className="mr-2 h-4 w-4" />
按批注修改
</DropdownMenuItem>
) : null}
{isLayerSplitEnabled && imageLayerSplitEnabledByToolbar ? (
<DropdownMenuItem onClick={() => handleAction("layer-split")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Layers}
item={imageLayerSplitItem}
/>
{imageLayerSplitItem.label}
</DropdownMenuItem>
) : null}
{imageResizeEnabled ? (
<DropdownMenuItem onClick={() => handleAction("resize")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Maximize2}
item={imageResizeItem}
/>
{imageResizeItem.label}
</DropdownMenuItem>
) : null}
{imageMultiAngleEnabled ? (
<DropdownMenuItem onClick={() => handleAction("angle")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
/>
<span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge
label={angleEditCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{imagePromptRegenerateEnabled ? (
<DropdownMenuItem
onClick={() => handleAction("prompt-regenerate")}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
<span>{imagePromptRegenerateItem.label}</span>
<ImageActionCreditBadge
label={promptRegenerateCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{imageTextEditEnabled ? (
<DropdownMenuItem onClick={() => handleAction("text-edit")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Type}
item={imageTextEditItem}
/>
{imageTextEditItem.label}
</DropdownMenuItem>
) : null}
{imagePsdTemplateReplaceEnabled ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{imageExtendEnabled ? (
<DropdownMenuItem onClick={() => handleAction("extend")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
/>
<span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} />
</DropdownMenuItem>
) : null}
{imageCropEnabled ? (
<DropdownMenuItem onClick={() => handleAction("crop")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Crop}
item={imageCropItem}
/>
{imageCropItem.label}
</DropdownMenuItem>
) : null}
{imageAdjustEnabled ? (
<DropdownMenuItem onClick={() => handleAction("adjust")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={SlidersHorizontal}
item={imageAdjustItem}
/>
{imageAdjustItem.label}
</DropdownMenuItem>
) : null}
{imageFlipRotateEnabled ? (
<DropdownMenuItem onClick={() => handleAction("flip-rotate")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FlipHorizontal2}
item={imageFlipRotateItem}
/>
{imageFlipRotateItem.label}
</DropdownMenuItem>
) : null}
{imageRenameEnabled ? (
<DropdownMenuItem
onClick={() => {
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Pencil}
item={imageRenameItem}
/>
{imageRenameItem.label}
</DropdownMenuItem>
) : null}
{hasHoverImageMorePrimaryActions &&
hasHoverImageMoreSecondaryActions ? (
<DropdownMenuSeparator />
) : null}
{imageDownloadEnabled ? (
<DropdownMenuItem
disabled={isDownloadingOriginalImage}
onClick={() => {
void handleDownload();
}}
>
{isDownloadingOriginalImage ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={imageDownloadItem}
/>
)}
{imageDownloadItem.label}
</DropdownMenuItem>
) : null}
{imageDeleteEnabled ? (
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={imageDeleteItem}
/>
{imageDeleteItem.label}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
{data.note && !isSelected && (
<div
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-1 rounded-full border border-[#f5e0a7] bg-white px-2 py-1 text-[11px] text-[#c48917] shadow-[0_0_0_1px_rgba(245,224,167,0.22)]"
title={imageNoteItem.label}
>
<CanvasToolbarIcon
className="h-3 w-3"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</div>
)}
{shortAgentExecutionSnapshotId ? (
<button
className={cn(
"absolute left-2 inline-flex items-center gap-1 rounded-full border border-[#dbe2ee] bg-white px-2.5 py-1 text-[11px] text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.3)] transition-colors hover:bg-[#f7f9ff] hover:text-[#16181d]",
data.note && !isSelected ? "top-10" : "top-2"
)}
onClick={() => setSnapshotDialogOpen(true)}
title={`执行快照:${agentExecutionSnapshotId}`}
type="button"
>
快照 {shortAgentExecutionSnapshotId}
</button>
) : null}
</div>
</div>
<AgentExecutionSnapshotDialog
onOpenChange={setSnapshotDialogOpen}
open={snapshotDialogOpen}
snapshotId={agentExecutionSnapshotId || null}
/>
{/* 批量处理对话框 */}
{batchProcessingEnabled ? (
<SceneFusionDialog
onBatchAddToCanvas={handleSceneFusionBatchAddToCanvas}
onOpenChange={setSceneFusionDialogOpen}
open={sceneFusionDialogOpen}
/>
) : null}
{/* 提示词重绘对话框 */}
<PromptRegenerateDialog
isProcessing={isProcessing}
nodeId={node.id}
onConfirm={handlePromptRegenerate}
onOpenChange={setPromptDialogOpen}
onPromptChange={setRegeneratePrompt}
open={promptDialogOpen}
prompt={regeneratePrompt}
/>
<Dialog onOpenChange={setPsdTemplateDialogOpen} open={psdTemplateDialogOpen}>
<DialogContent className="w-[calc(100vw-1.5rem)] max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileImage className="h-5 w-5" />
模版替换
</DialogTitle>
<DialogDescription>
当前图片会作为替换素材带入 PSD 模版工作台。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-3 rounded-lg border bg-muted/20 p-3">
<img
alt={displayTitle}
className="aspect-square h-[88px] w-[88px] rounded-md border bg-background object-cover"
src={buildImageProxyUrl(data.src, { width: 240 })}
/>
<div className="min-w-0 self-center">
<div className="truncate font-medium text-sm">{displayTitle}</div>
<div className="mt-1 text-muted-foreground text-xs">
已选择 1 张画布图片
</div>
</div>
</div>
{isLoadingPsdTemplateSummary ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在读取模版品牌...
</div>
) : availablePsdTemplateBrands.length > 0 ? (
<div className="space-y-2">
<Label>快捷选择</Label>
<div className="flex max-h-32 flex-wrap gap-2 overflow-y-auto">
{availablePsdTemplateBrands.map((brand) => {
const active = psdTemplateBrandName.trim() === brand;
return (
<button
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted"
)}
key={brand}
onClick={() => setPsdTemplateBrandName(brand)}
type="button"
>
{brand}
</button>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor={`psd-template-brand-${node.id}`}>品牌名</Label>
<Input
id={`psd-template-brand-${node.id}`}
onChange={(event) => setPsdTemplateBrandName(event.target.value)}
placeholder="选择或输入已配置 PSD 模版的品牌名"
value={psdTemplateBrandName}
/>
</div>
</div>
<DialogFooter>
<Button onClick={() => setPsdTemplateDialogOpen(false)} variant="outline">
取消
</Button>
<Button
disabled={!psdTemplateBrandName.trim()}
onClick={handleLaunchPsdTemplateReplace}
>
<FileImage className="mr-2 h-4 w-4" />
打开模版替换
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ThreeDGenerationDialog
costLabel={
threedGenerationDialogMode === "world"
? worldImageCostLabel
: tripoImageToModelCostLabel
}
costShortLabel={
threedGenerationDialogMode === "world"
? worldImageCostShortLabel
: tripoImageToModelCostShortLabel
}
isProcessing={isProcessing}
mode={threedGenerationDialogMode}
onConfirm={handleConfirmThreedGeneration}
onModeChange={setThreedGenerationDialogMode}
onTripoModelChange={setSelectedTripoModelVersion}
onWorldModelChange={setSelectedWorldModelVersion}
selectedTripoModelEntry={selectedTripoModelEntry}
selectedTripoModelVersion={selectedTripoModelVersion}
selectedWorldModelEntry={selectedWorldModelEntry}
selectedWorldModelVersion={selectedWorldModelVersion}
tripoModelOptions={tripoModelOptions}
worldModelOptions={worldModelOptions}
/>
{localEditMode &&
!isMultiSelected &&
data.src &&
!isThisNodePicking &&
!isCompareMode &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed z-[9999] flex items-center gap-2 rounded-[24px] border border-slate-200 bg-white px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:shadow-[0_16px_44px_rgba(0,0,0,0.45)]"
data-local-edit-control="true"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={boxCutoutToolbarRef}
style={{
top: boxCutoutToolbarPosition.top,
left: boxCutoutToolbarPosition.left,
transform: `scale(${FLOATING_TOOLBAR_SCALE})`,
transformOrigin: "top left",
pointerEvents: "auto",
maxWidth: `${Math.max(
180,
Math.floor(boxCutoutToolbarPosition.maxWidth / FLOATING_TOOLBAR_SCALE)
)}px`,
overflowX: "auto",
scrollbarWidth: "none",
}}
>
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-2 rounded-xl bg-slate-100 px-3 font-medium text-[13px] text-slate-700 dark:bg-zinc-800 dark:text-slate-100">
<Wand2 className="h-4 w-4 text-slate-950 dark:text-white" />
<span className="whitespace-nowrap">
{localEditOperationMode === "scale_adjust" ? "比例调整" : "局部编辑"}
</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-slate-300">
{localEditRegions.length}/{LOCAL_EDIT_MAX_REGIONS}
</span>
</div>
<div className="inline-flex shrink-0 items-center gap-1 rounded-xl bg-slate-100 p-1 dark:bg-zinc-800">
<Button
className={cn(
"h-8 rounded-lg px-2.5 text-[12px] font-semibold",
localEditOperationMode === "local_edit"
? "bg-white text-slate-950 shadow-sm dark:bg-white dark:text-slate-950"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white"
)}
disabled={isSubmittingLocalEdit}
onClick={() => {
setLocalEditOperationMode("local_edit");
}}
type="button"
variant="ghost"
>
局编
</Button>
<Button
className={cn(
"h-8 rounded-lg px-2.5 text-[12px] font-semibold",
localEditOperationMode === "scale_adjust"
? "bg-white text-slate-950 shadow-sm dark:bg-white dark:text-slate-950"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white"
)}
disabled={isSubmittingLocalEdit}
onClick={() => {
setLocalEditOperationMode("scale_adjust");
setEditingLocalEditRegionId(null);
}}
type="button"
variant="ghost"
>
比例
</Button>
</div>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-zinc-800" />
<Button
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-950 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:border dark:border-white/14 dark:bg-black dark:text-white dark:disabled:border-transparent dark:disabled:bg-slate-700 dark:disabled:text-slate-400 dark:hover:bg-zinc-900"
disabled={
isSubmittingLocalEdit ||
localEditRegions.length === 0 ||
(localEditOperationMode === "scale_adjust" &&
localEditRegions.some((region) => !region.targetRect))
}
onClick={() => void handleSubmitLocalEdit()}
title={
localEditOperationMode === "scale_adjust"
? "生成比例调整结果"
: "生成局部编辑结果"
}
type="button"
variant="ghost"
>
{isSubmittingLocalEdit ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
<span className="whitespace-nowrap">生成</span>
</Button>
<Button
className="h-10 shrink-0 rounded-xl px-3 text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isSubmittingLocalEdit || localEditRegions.length === 0}
onClick={clearLocalEditRegions}
title="清空区域"
type="button"
variant="ghost"
>
清空
</Button>
<Button
className="h-10 shrink-0 rounded-xl px-3 text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isSubmittingLocalEdit}
onClick={resetLocalEditState}
title="退出局部编辑"
type="button"
variant="ghost"
>
退出
</Button>
</div>,
document.body
)}
{aiWashQuickPanelOpen &&
imageAiWashQuickEnabled &&
isSelected &&
!isMultiSelected &&
data.src &&
!isEditingTitle &&
!sceneFusionDialogOpen &&
!promptDialogOpen &&
!threedGenerationDialogMode &&
!notePanelOpen &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed z-[10000] overflow-hidden rounded-[14px] border border-slate-200 bg-white text-slate-950 shadow-[0_18px_44px_rgba(15,23,42,0.18)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
data-canvas-no-drag="true"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: aiWashQuickPanelPosition.top,
left: aiWashQuickPanelPosition.left,
width: aiWashQuickPanelPosition.width,
maxHeight: aiWashQuickPanelPosition.maxHeight,
overflowY: "auto",
scrollbarWidth: "none",
}}
>
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3 dark:border-white/10">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-slate-950 dark:text-white">
<CanvasToolbarIcon
className="h-5 w-5"
fallback={Sparkles}
iconUrl={aiWashQuickConfig.modeIconUrl}
/>
</span>
<div className="min-w-0 truncate text-[17px] font-semibold tracking-[-0.02em]">
{aiWashQuickModeLabel}
</div>
</div>
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
onClick={() => setAiWashQuickPanelOpen(false)}
title="关闭"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{aiWashConfigLoading ? (
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 text-[13px] text-slate-500 dark:border-white/10 dark:text-white/58">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载 AI洗图 配置...
</div>
) : null}
<div className="divide-y divide-slate-100 dark:divide-white/10">
<div className="px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-[14px] font-semibold text-slate-950 dark:text-white">
<SlidersHorizontal className="h-4 w-4" />
目标
</div>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
{getAiWashStatusLabel(aiWashQuickStatus)}
</span>
</div>
<Select
onValueChange={(value) => {
setAiWashQuickPresetId(value);
setAiWashQuickStatus("idle");
}}
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
>
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
<SelectValue placeholder="选择渲染目标" />
</SelectTrigger>
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
{aiWashQuickPresets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.title}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedAiWashQuickPreset?.description ? (
<div className="mt-2 line-clamp-2 text-[12px] leading-5 text-slate-500 dark:text-white/46">
{selectedAiWashQuickPreset.description}
</div>
) : null}
</div>
<div className="px-4 py-3">
<div className="mb-2 text-[14px] font-semibold text-slate-950 dark:text-white">
参数
</div>
{shouldShowAiWashQuickModelSelector ? (
<div className="mb-3 space-y-1.5">
<Label className="text-[12px] font-medium text-slate-500 dark:text-white/54">
生图模型
</Label>
<Select
onValueChange={setAiWashQuickModelId}
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
>
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
<SelectValue placeholder="选择生图模型" />
</SelectTrigger>
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
{aiWashQuickSelectableModels.map((model) => (
<SelectItem
disabled={model.selectable === false}
key={model.id}
value={model.id}
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{model.displayName}
</span>
<span className="shrink-0 text-slate-500 text-xs dark:text-white/52">
{model.unitCost > 0
? `${model.unitCost} 积分/张`
: "未计费"}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-[12px] leading-5 text-slate-500 dark:text-white/50">
{selectedAiWashQuickModel
? `${selectedAiWashQuickModel.displayName} · ${
selectedAiWashQuickModel.unitCost > 0
? `${selectedAiWashQuickModel.unitCost} 积分/张`
: "未设置积分"
}`
: aiWashModelOptionsLoading
? "正在加载可选模型..."
: "没有可选模型"}
</div>
</div>
) : null}
<div className="-mx-4 divide-y divide-slate-100 dark:divide-white/10">
{aiWashQuickControls.map((control) => {
const selectedIndex =
aiWashQuickLevels[control.id] ?? control.defaultLevel;
const activeLevel =
getAiWashControlLevelByIndex(control, selectedIndex) ||
control.levels[0] ||
null;
return (
<div
className="px-4 py-3"
key={control.id}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[14px] font-semibold">
{control.label}
</div>
<span className="min-w-8 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[12px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
{activeLevel?.label || selectedIndex + 1}
</span>
</div>
<Slider
className="w-full"
max={Math.max(0, control.levels.length - 1)}
min={0}
onValueChange={([next]) =>
setAiWashQuickLevels((current) => ({
...current,
[control.id]: Math.round(next ?? 0),
}))
}
rangeClassName="bg-slate-950 dark:bg-white"
step={1}
thumbClassName="h-3.5 w-3.5 border border-slate-300 bg-white shadow-[0_0_0_4px_rgba(15,23,42,0.06)]"
trackClassName="h-1.5 bg-slate-200 dark:bg-white/14"
value={[selectedIndex]}
/>
</div>
);
})}
</div>
</div>
<div className="px-3 py-3">
<Button
className="h-10 w-full rounded-[10px] bg-slate-950 text-[14px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
disabled={
isSubmittingAiWashQuick ||
!selectedAiWashQuickPreset ||
!aiWashQuickHasExecutableModel
}
onClick={() => void handleSubmitAiWashQuick()}
type="button"
>
{isSubmittingAiWashQuick ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-2 h-3.5 w-3.5" />
)}
{isSubmittingAiWashQuick
? "生成中"
: `${aiWashQuickModeLabel}${
!aiWashQuickConfig.hideModelSelector &&
selectedAiWashQuickModel?.unitCost
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
: ""
}`}
</Button>
</div>
</div>
</div>,
document.body
)}
{isSelected &&
!isMultiSelected &&
data.src &&
!isEditingTitle &&
!sceneFusionDialogOpen &&
!promptDialogOpen &&
!threedGenerationDialogMode &&
!notePanelOpen &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
typeof document !== "undefined" &&
createPortal(
<TooltipProvider delayDuration={200}>
<div
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
ref={toolbarRef}
style={{
top: toolbarPosition.top,
left: toolbarPosition.left,
transform: `scale(${FLOATING_TOOLBAR_SCALE})`,
transformOrigin: "top left",
pointerEvents: "auto",
maxWidth: `${Math.max(
180,
Math.floor(toolbarPosition.maxWidth / FLOATING_TOOLBAR_SCALE)
)}px`,
overflowX: "visible",
scrollbarWidth: "none",
}}
>
{imageAddToChatEnabled ? (
<Button
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
disabled={isProcessing || isAddingToChat}
onClick={handleAddToChat}
title={imageAddToChatItem.label}
variant="ghost"
>
{isAddingToChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
active
className="h-4 w-4 dark:invert-0"
fallback={MessageSquarePlus}
item={imageAddToChatItem}
/>
)}
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
</Button>
) : null}
{imageAddToChatEnabled &&
(hasSelectedImagePrimaryToolbarActions ||
hasSelectedImageSecondaryToolbarActions) ? (
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-zinc-800" />
) : null}
{isSelectedImageActionInline("pickObject") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isPickerLoading || isThisNodePicking}
onClick={handleStartPicking}
title={imagePickObjectItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Crosshair}
item={imagePickObjectItem}
/>
{imagePickObjectItem.label}
</Button>
) : null}
{isSelectedImageActionInline("enhance") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("enhance")}
title={imageEnhanceItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
item={imageEnhanceItem}
/>
{imageEnhanceItem.label}
<ImageActionCreditBadge compact label={enhanceCostShortLabel} />
</Button>
) : null}
{isSelectedImageActionInline("removeBackground") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("rmbg")}
title={imageRemoveBackgroundItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Scissors}
item={imageRemoveBackgroundItem}
/>
{imageRemoveBackgroundItem.label}
<ImageActionCreditBadge
compact
label={removeBackgroundCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("aiWashQuick") ? (
<Button
className={cn(
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
aiWashQuickPanelOpen
? "bg-slate-950 text-white hover:bg-slate-900 hover:text-white dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100"
: "text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
)}
disabled={isProcessing || isSubmittingAiWashQuick}
onClick={() => handleAction("ai-wash")}
title={aiWashQuickModeLabel}
variant="ghost"
>
{isSubmittingAiWashQuick ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
active={aiWashQuickPanelOpen}
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
iconUrl={aiWashQuickConfig.modeIconUrl}
/>
)}
{aiWashQuickModeLabel}
<ImageActionCreditBadge
compact
label={aiWashQuickCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("textEdit") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
onClick={() => handleAction("text-edit")}
title={imageTextEditItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Type}
item={imageTextEditItem}
/>
{imageTextEditItem.label}
</Button>
) : null}
{isSelectedImageActionInline("boxCutout") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("box-cutout")}
title={imageBoxCutoutItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Scan}
item={imageBoxCutoutItem}
/>
{imageBoxCutoutItem.label}
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
</Button>
) : null}
{isSelectedImageActionInline("localEdit") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isSubmittingLocalEdit}
onClick={() => handleAction("local-edit")}
title={imageLocalEditItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Wand2}
item={imageLocalEditItem}
/>
{imageLocalEditItem.label}
<ImageActionCreditBadge
compact
label={localEditCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("psdTemplate") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("psd-template")}
title={imagePsdTemplateReplaceItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
{imagePsdTemplateReplaceItem.label}
</Button>
) : null}
{isSelectedImageActionInline("removeWatermark") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("watermark")}
title={imageRemoveWatermarkItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
{imageRemoveWatermarkItem.label}
<ImageActionCreditBadge
compact
label={removeWatermarkCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("resize") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("resize")}
title={imageResizeItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Maximize2}
item={imageResizeItem}
/>
{imageResizeItem.label}
</Button>
) : null}
{isSelectedImageActionInline("multiAngle") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("angle")}
title={imageMultiAngleItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
/>
{imageMultiAngleItem.label}
<ImageActionCreditBadge
compact
label={angleEditCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("generate3dModel") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("generate-3d-model")}
title={imageGenerate3dModelItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Box}
item={imageGenerate3dModelItem}
/>
{imageGenerate3dModelItem.label}
<ImageActionCreditBadge
compact
label={tripoImageToModelCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("generate3dWorld") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("generate-3d-world")}
title={imageGenerate3dWorldItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Globe2}
item={imageGenerate3dWorldItem}
/>
{imageGenerate3dWorldItem.label}
<ImageActionCreditBadge
compact
label={worldImageCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("layerSplit") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("layer-split")}
title={imageLayerSplitItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Layers}
item={imageLayerSplitItem}
/>
{imageLayerSplitItem.label}
</Button>
) : null}
{isSelectedImageActionInline("extend") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("extend")}
title={imageExtendItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
/>
{imageExtendItem.label}
<ImageActionCreditBadge compact label={extendCostShortLabel} />
</Button>
) : null}
{isSelectedImageActionInline("crop") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("crop")}
title={imageCropItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Crop}
item={imageCropItem}
/>
{imageCropItem.label}
</Button>
) : null}
{isSelectedImageActionInline("adjust") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("adjust")}
title={imageAdjustItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={SlidersHorizontal}
item={imageAdjustItem}
/>
{imageAdjustItem.label}
</Button>
) : null}
{isSelectedImageActionInline("flipRotate") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("flip-rotate")}
title={imageFlipRotateItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={FlipHorizontal2}
item={imageFlipRotateItem}
/>
{imageFlipRotateItem.label}
</Button>
) : null}
{isSelectedImageActionInline("promptRegenerate") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("prompt-regenerate")}
title={imagePromptRegenerateItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
{imagePromptRegenerateItem.label}
<ImageActionCreditBadge
compact
label={promptRegenerateCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("note") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("note")}
title={imageNoteItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</Button>
) : null}
{hasSelectedImagePrimaryToolbarActions &&
hasSelectedImageSecondaryToolbarActions ? (
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-zinc-800" />
) : null}
{hasSelectedImageMoreMenu ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
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-zinc-800"
disabled={isProcessing}
size="icon"
title={imageNodeMoreItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreHorizontal}
item={imageNodeMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100"
side="bottom"
>
{isSelectedImageActionInMore("boxCutout") ? (
<DropdownMenuItem onClick={() => handleAction("box-cutout")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Scan}
item={imageBoxCutoutItem}
/>
<span>{imageBoxCutoutItem.label}</span>
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("localEdit") ? (
<DropdownMenuItem onClick={() => handleAction("local-edit")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Wand2}
item={imageLocalEditItem}
/>
<span>{imageLocalEditItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("psdTemplate") ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("removeWatermark") ? (
<DropdownMenuItem onClick={() => handleAction("watermark")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
<span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge
label={removeWatermarkCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("resize") ? (
<DropdownMenuItem onClick={() => handleAction("resize")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Maximize2}
item={imageResizeItem}
/>
{imageResizeItem.label}
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("multiAngle") ? (
<DropdownMenuItem onClick={() => handleAction("angle")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
/>
<span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge
label={angleEditCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("generate3dModel") ? (
<DropdownMenuItem
onClick={() => handleAction("generate-3d-model")}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={imageGenerate3dModelItem}
/>
<span>{imageGenerate3dModelItem.label}</span>
<ImageActionCreditBadge
label={tripoImageToModelCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("generate3dWorld") ? (
<DropdownMenuItem
onClick={() => handleAction("generate-3d-world")}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Globe2}
item={imageGenerate3dWorldItem}
/>
<span>{imageGenerate3dWorldItem.label}</span>
<ImageActionCreditBadge label={worldImageCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("layerSplit") ? (
<DropdownMenuItem onClick={() => handleAction("layer-split")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Layers}
item={imageLayerSplitItem}
/>
{imageLayerSplitItem.label}
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("extend") ? (
<DropdownMenuItem onClick={() => handleAction("extend")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
/>
<span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("crop") ? (
<DropdownMenuItem onClick={() => handleAction("crop")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Crop}
item={imageCropItem}
/>
{imageCropItem.label}
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("adjust") ? (
<DropdownMenuItem onClick={() => handleAction("adjust")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={SlidersHorizontal}
item={imageAdjustItem}
/>
{imageAdjustItem.label}
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("flipRotate") ? (
<DropdownMenuItem onClick={() => handleAction("flip-rotate")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FlipHorizontal2}
item={imageFlipRotateItem}
/>
{imageFlipRotateItem.label}
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("promptRegenerate") ? (
<DropdownMenuItem
onClick={() => handleAction("prompt-regenerate")}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
<span>{imagePromptRegenerateItem.label}</span>
<ImageActionCreditBadge
label={promptRegenerateCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("note") ? (
<DropdownMenuItem onClick={() => handleAction("note")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</DropdownMenuItem>
) : null}
{hasCanvasAgentAnnotations ? (
<DropdownMenuItem onClick={requestCanvasAgentAnnotationEdit}>
<Wand2 className="mr-2 h-4 w-4" />
按批注修改
</DropdownMenuItem>
) : null}
{(hasSelectedImageMorePrimaryActions || hasCanvasAgentAnnotations) &&
imageDeleteEnabled ? (
<DropdownMenuSeparator />
) : null}
{imageDeleteEnabled ? (
<DropdownMenuItem
className="text-red-600 focus:text-red-700"
onClick={() => deleteNode(node.id)}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={imageDeleteItem}
/>
{imageDeleteItem.label}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{imageDownloadEnabled ? (
<Button
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-zinc-800"
disabled={isProcessing || isDownloadingOriginalImage}
onClick={() => {
void handleDownload();
}}
size="icon"
title={imageDownloadItem.label}
variant="ghost"
>
{isDownloadingOriginalImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Download}
item={imageDownloadItem}
/>
)}
</Button>
) : null}
</div>
</TooltipProvider>,
document.body
)}
{isSelected &&
!isMultiSelected &&
data.src &&
boxCutoutMode &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed z-[9999] flex items-center rounded-[24px] border border-slate-200 bg-white px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:shadow-[0_16px_44px_rgba(0,0,0,0.45)]"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={boxCutoutToolbarRef}
style={{
top: boxCutoutToolbarPosition.top,
left: boxCutoutToolbarPosition.left,
transform: `scale(${FLOATING_TOOLBAR_SCALE})`,
transformOrigin: "top left",
pointerEvents: "auto",
maxWidth: `${Math.max(
180,
Math.floor(boxCutoutToolbarPosition.maxWidth / FLOATING_TOOLBAR_SCALE)
)}px`,
overflowX: "auto",
scrollbarWidth: "none",
}}
>
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xl bg-slate-100 px-3 font-medium text-[13px] text-slate-700 dark:bg-zinc-800 dark:text-slate-100">
<CanvasToolbarIcon
className="h-4 w-4 text-[#27a8ff]"
fallback={Scan}
item={imageBoxCutoutItem}
/>
<span className="whitespace-nowrap">{boxCutoutHintText}</span>
</div>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-zinc-800" />
<Button
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] 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:disabled:bg-slate-700 dark:disabled:text-slate-400 dark:hover:bg-slate-200"
disabled={isProcessing || !boxCutoutSelection}
onClick={() => void handleConfirmBoxCutout()}
title={imageConfirmCutoutItem.label}
variant="ghost"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
active
className="h-4 w-4"
fallback={Check}
item={imageConfirmCutoutItem}
/>
)}
<span className="whitespace-nowrap">
{imageConfirmCutoutItem.label}
</span>
</Button>
<Button
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-zinc-800"
onClick={() => resetBoxCutoutState()}
size="icon"
title={imageCancelCutoutItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={X}
item={imageCancelCutoutItem}
/>
</Button>
</div>,
document.body
)}
{/* 图片批注对话框 */}
<Dialog onOpenChange={setNotePanelOpen} open={notePanelOpen}>
<DialogContent
className="max-h-[85vh] overflow-y-auto border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
overlayClassName="bg-black"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<StickyNote className="h-5 w-5" />
图片批注
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<Textarea
className="min-h-[180px] resize-y border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
onChange={(e) => setDraftNote(e.target.value)}
placeholder="记录这张图的修改要求、局部说明或交付批注..."
rows={8}
value={draftNote}
/>
<p className="text-muted-foreground text-xs">
批注会作为 Agent 修改图片时的上下文,不会直接覆盖在画面上。
</p>
</div>
<DialogFooter>
<Button onClick={() => setNotePanelOpen(false)} variant="outline">
取消
</Button>
<Button onClick={handleSaveNote}>保存批注</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isSelected &&
cropPanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed z-[9999] w-[474px] overflow-hidden rounded-[30px] border border-[#ebe7df] bg-white shadow-[0_24px_64px_rgba(17,24,39,0.12)]"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: cropPanelPosition.top,
left: cropPanelPosition.left,
transform: `scale(${IMAGE_EDIT_PANEL_SCALE})`,
transformOrigin: "top left",
}}
>
<div className="px-8 pt-8 pb-6">
<h3 className="text-[20px] font-semibold tracking-[0] text-[#18181b]">
{imageCropItem.label}
</h3>
<div className="mt-6 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<div className="rounded-[18px] bg-[#f5f4f1] px-5 py-4">
<div className="flex items-center gap-3">
<span className="text-[18px] font-medium text-[#666a72]">W</span>
<Input
className="h-auto border-0 bg-transparent p-0 text-[20px] font-medium text-[#2a2d33] shadow-none focus-visible:ring-0"
inputMode="numeric"
onChange={(event) =>
handleChangeCropDimension("width", event.target.value)
}
value={cropPixelSize.width ? String(cropPixelSize.width) : ""}
/>
</div>
</div>
<button
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-[#4f84ff] transition-colors hover:bg-[#eef4ff] hover:text-[#3368f1]"
onClick={() => setCropLockAspectRatio((prev) => !prev)}
title={cropLockAspectRatio ? "解锁比例" : "锁定比例"}
type="button"
>
{cropLockAspectRatio ? (
<Link2 className="h-5 w-5" />
) : (
<Unlink2 className="h-5 w-5" />
)}
</button>
<div className="rounded-[18px] bg-[#f5f4f1] px-5 py-4">
<div className="flex items-center gap-3">
<span className="text-[18px] font-medium text-[#666a72]">H</span>
<Input
className="h-auto border-0 bg-transparent p-0 text-[20px] font-medium text-[#2a2d33] shadow-none focus-visible:ring-0"
inputMode="numeric"
onChange={(event) =>
handleChangeCropDimension("height", event.target.value)
}
value={cropPixelSize.height ? String(cropPixelSize.height) : ""}
/>
</div>
</div>
</div>
<div className="mt-8">
<div className="text-[16px] font-medium text-[#6d7077]">预设</div>
<div className="mt-4 max-h-[560px] space-y-3 overflow-y-auto pr-1">
{CROP_PRESET_GROUPS.map((group) => {
const expanded = cropExpandedGroups[group.key];
return (
<div key={group.key}>
<button
className="flex w-full items-center gap-3 py-2 text-left"
onClick={() => handleToggleCropGroup(group.key)}
type="button"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-[#65676d]" />
) : (
<ChevronRight className="h-4 w-4 text-[#65676d]" />
)}
<span className="text-[17px] font-medium text-[#2b2f36]">
{group.label}
</span>
</button>
{expanded ? (
<div className="mt-2 space-y-2">
{group.options.map((option) => {
const selected = cropPresetId === option.id;
return (
<button
className={cn(
"flex w-full items-center gap-4 rounded-[18px] px-5 py-3 text-left transition-colors",
selected
? "bg-[#f0efec] text-[#1f2228]"
: "hover:bg-[#f7f6f3] text-[#2b2f36]"
)}
key={option.id}
onClick={() => handleSelectCropPreset(option.id)}
type="button"
>
<span className="flex h-9 w-9 items-center justify-center rounded-[10px] border border-[#d6d3cd] bg-white text-[#272a31]">
{selected ? (
<Check className="h-4 w-4" />
) : (
<span className="h-4 w-4 rounded-[4px] border border-[#76797f]" />
)}
</span>
<span className="text-[18px] font-medium tracking-[0]">
{option.label}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
})}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 border-[#f0ede7] border-t px-7 py-6">
<button
className="inline-flex h-14 items-center justify-center rounded-[18px] border border-[#e3ddd3] bg-white text-[18px] font-medium text-[#26262a] transition-colors hover:bg-[#faf7f1]"
disabled={isApplyingCrop}
onClick={() => {
setCropPanelOpen(false);
setCropInteraction(null);
}}
type="button"
>
取消
</button>
<button
className="inline-flex h-14 items-center justify-center rounded-[18px] bg-[#2c2c2f] text-[18px] font-semibold text-white transition-colors hover:bg-[#202023] disabled:cursor-not-allowed disabled:bg-[#c9c6bf] disabled:text-white/80"
disabled={isApplyingCrop || !cropRect}
onClick={() => void handleApplyCrop()}
type="button"
>
{isApplyingCrop ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
裁剪中
</span>
) : (
"完成"
)}
</button>
</div>
</div>,
document.body
)}
<ImageAdjustFloatingPanel
draft={imageAdjustmentsDraft}
hasImageSource={Boolean(data.src)}
isSelected={isSelected}
label={imageAdjustItem.label}
onClose={() => setImageAdjustPanelOpen(false)}
onDraftChange={setImageAdjustmentsDraft}
onReset={handleResetImageAdjustments}
onTabChange={setImageAdjustTab}
open={imageAdjustPanelOpen}
position={imageAdjustPanelPosition}
scale={IMAGE_EDIT_PANEL_SCALE}
tab={imageAdjustTab}
/>
{isSelected &&
flipRotatePanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
<TooltipProvider delayDuration={120}>
<div
className="fixed z-[9999] flex max-w-[calc(100vw-24px)] items-center overflow-hidden rounded-[26px] border border-[#ece8e1] bg-white shadow-[0_18px_42px_rgba(15,23,42,0.14)]"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: flipRotatePanelPosition.top,
left: flipRotatePanelPosition.left,
maxWidth: `${Math.floor(
flipRotatePanelPosition.maxWidth / IMAGE_EDIT_PANEL_SCALE
)}px`,
transform: `scale(${IMAGE_EDIT_PANEL_SCALE})`,
transformOrigin: "top left",
}}
>
<div className="flex h-[82px] shrink-0 items-center gap-3 px-7">
<FlipHorizontal2 className="h-5 w-5 text-[#2d3136]" />
<span className="whitespace-nowrap text-[18px] font-semibold tracking-[0] text-[#22252b]">
{imageFlipRotateItem.label}
</span>
</div>
<div className="h-[82px] w-px shrink-0 bg-[#ece8e1]" />
<div className="flex h-[82px] min-w-[138px] shrink-0 items-center gap-3 px-7">
<RotateCcw className="h-5 w-5 text-[#4f5561]" />
<span className="text-[18px] font-medium tabular-nums tracking-[0] text-[#23262d]">
0°
</span>
</div>
<div className="h-[82px] w-px shrink-0 bg-[#ece8e1]" />
<div className="flex items-center gap-2 px-3 py-2">
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex h-14 w-14 items-center justify-center rounded-[18px] text-[#2b2f36] transition-colors hover:bg-[#f3f1ec] disabled:cursor-not-allowed disabled:text-[#b9b5ae]"
disabled={isApplyingFlipRotate || !hasFlipRotateReset}
onClick={() => void handleApplyFlipRotate("reset")}
type="button"
>
{isApplyingFlipRotate ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<RotateCcw className="h-5 w-5" />
)}
</button>
</TooltipTrigger>
<TooltipContent>恢复原图</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex h-14 w-14 items-center justify-center rounded-[18px] text-[#2b2f36] transition-colors hover:bg-[#f3f1ec] disabled:cursor-not-allowed disabled:text-[#b9b5ae]"
disabled={isApplyingFlipRotate}
onClick={() => void handleApplyFlipRotate("horizontal")}
type="button"
>
{isApplyingFlipRotate ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<FlipHorizontal2 className="h-5 w-5" />
)}
</button>
</TooltipTrigger>
<TooltipContent>水平翻转</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex h-14 w-14 items-center justify-center rounded-[18px] text-[#2b2f36] transition-colors hover:bg-[#f3f1ec] disabled:cursor-not-allowed disabled:text-[#b9b5ae]"
disabled={isApplyingFlipRotate}
onClick={() => void handleApplyFlipRotate("vertical")}
type="button"
>
{isApplyingFlipRotate ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<FlipVertical2 className="h-5 w-5" />
)}
</button>
</TooltipTrigger>
<TooltipContent>垂直翻转</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex h-14 w-14 items-center justify-center rounded-[18px] text-[#2b2f36] transition-colors hover:bg-[#f3f1ec] disabled:cursor-not-allowed disabled:text-[#b9b5ae]"
disabled={isApplyingFlipRotate}
onClick={() => setFlipRotatePanelOpen(false)}
type="button"
>
<X className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>关闭</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>,
document.body
)}
{isSelected &&
anglePanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed z-[9999] w-[520px] rounded-[28px] border border-[#ececef] bg-white p-6 shadow-[0_24px_64px_rgba(15,23,42,0.12)]"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: anglePanelPosition.top,
left: anglePanelPosition.left,
transform: `scale(${ANGLE_PANEL_SCALE})`,
transformOrigin: "top left",
}}
>
<div className="flex items-center justify-between">
<h3 className="text-[20px] font-semibold tracking-[0] text-[#17181c]">
多角度
</h3>
<button
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-[#1f2024] transition-colors hover:bg-[#f5f6f8]"
disabled={isApplyingAngleEdit}
onClick={handleResetAnglePanel}
type="button"
>
<RefreshCw className="h-5 w-5" />
</button>
</div>
<div className="mt-5 rounded-[18px] border border-[#ededf0] bg-[#f5f5f6] p-1">
<div className="grid grid-cols-2 gap-1">
{(
[
{ id: "subject", label: "主体" },
{ id: "camera", label: "摄像头" },
] as const
).map((item) => {
const active = anglePanelMode === item.id;
return (
<button
className={cn(
"h-[52px] rounded-[16px] text-[16px] font-semibold tracking-[0] transition-all",
active
? "bg-white text-[#1f2023] shadow-[0_1px_2px_rgba(16,24,40,0.06)]"
: "text-[#767982] hover:text-[#2d3036]"
)}
disabled={isApplyingAngleEdit}
key={item.id}
onClick={() => setAnglePanelMode(item.id)}
type="button"
>
{item.label}
</button>
);
})}
</div>
</div>
<div className="relative mt-4 overflow-hidden rounded-[26px] border border-[#efeff2] bg-[#fcfcfd] px-4 py-4">
<div
className={cn(
"relative flex h-[332px] items-center justify-center overflow-hidden rounded-[22px] [perspective:1200px]",
isApplyingAngleEdit
? "cursor-default"
: isAnglePreviewDragging
? "cursor-grabbing"
: "cursor-grab"
)}
onLostPointerCapture={handleAnglePreviewPointerEnd}
onPointerCancel={handleAnglePreviewPointerEnd}
onPointerDown={handleAnglePreviewPointerDown}
onPointerMove={handleAnglePreviewPointerMove}
onPointerUp={handleAnglePreviewPointerEnd}
style={{ touchAction: "none" }}
>
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center"
style={angleOrbitWireframeStyle}
>
<div
className="relative shrink-0 [transform-style:preserve-3d]"
style={{
width: `${ANGLE_PANEL_WIREFRAME_DIAMETER}px`,
height: `${ANGLE_PANEL_WIREFRAME_DIAMETER}px`,
}}
>
<div className="absolute inset-0 rounded-full border border-[#e4e8ef]/90" />
{ANGLE_PREVIEW_VERTICAL_RING_ROTATIONS.map((rotation, index) => (
<div
className="absolute inset-0 rounded-full border border-[#e7ebf2]"
key={`angle-vertical-ring-${rotation}`}
style={{
backfaceVisibility: "hidden",
opacity: 0.9 - index * 0.026,
transform: `rotateY(${rotation}deg)`,
transformStyle: "preserve-3d",
}}
/>
))}
{ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS.map((rotation, index) => (
<div
className="absolute inset-0 rounded-full border border-[#e6eaf1]"
key={`angle-horizontal-ring-${rotation}`}
style={{
backfaceVisibility: "hidden",
opacity: rotation === 0 ? 0.94 : 0.82 - index * 0.045,
transform: `rotateX(${rotation}deg)`,
transformStyle: "preserve-3d",
}}
/>
))}
{ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS.map((rotation, index) => (
<Fragment key={`angle-diagonal-ring-${rotation}`}>
<div
className="absolute inset-0 rounded-full border border-[#edf0f5]"
style={{
backfaceVisibility: "hidden",
opacity: 0.54 - index * 0.035,
transform: `rotateX(67deg) rotateY(${rotation}deg)`,
transformStyle: "preserve-3d",
}}
/>
<div
className="absolute inset-0 rounded-full border border-[#edf0f5]"
style={{
backfaceVisibility: "hidden",
opacity: 0.5 - index * 0.032,
transform: `rotateX(-67deg) rotateY(${rotation + 12}deg)`,
transformStyle: "preserve-3d",
}}
/>
</Fragment>
))}
</div>
</div>
<button
className="absolute top-[24px] left-1/2 z-[3] inline-flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full text-[#a0a3ab] transition-colors hover:bg-white hover:text-[#30333a]"
disabled={isApplyingAngleEdit}
onClick={() =>
setAngleVerticalAngle((prev) => clampNumber(prev + 5, -30, 60))
}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
className="absolute bottom-[24px] left-1/2 z-[3] inline-flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full text-[#a0a3ab] transition-colors hover:bg-white hover:text-[#30333a]"
disabled={isApplyingAngleEdit}
onClick={() =>
setAngleVerticalAngle((prev) => clampNumber(prev - 5, -30, 60))
}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
className="absolute top-1/2 left-[18px] z-[3] inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-[#a0a3ab] transition-colors hover:bg-white hover:text-[#30333a]"
disabled={isApplyingAngleEdit}
onClick={() =>
setAngleHorizontalAngle((prev) =>
clampNumber(prev - 10, -180, 180)
)
}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
className="absolute top-1/2 right-[18px] z-[3] inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-[#a0a3ab] transition-colors hover:bg-white hover:text-[#30333a]"
disabled={isApplyingAngleEdit}
onClick={() =>
setAngleHorizontalAngle((prev) =>
clampNumber(prev + 10, -180, 180)
)
}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
<ChevronRight className="h-4 w-4" />
</button>
{anglePanelMode === "subject" ? (
<div
className="relative z-[2]"
style={{
width: `${anglePreviewFrameSize.width}px`,
height: `${anglePreviewFrameSize.height}px`,
transform: `rotateX(${-angleVerticalAngle * 0.5}deg) rotateY(${angleHorizontalAngle * 0.68}deg) scale(${anglePreviewScale})`,
transformStyle: "preserve-3d",
transition: isAnglePreviewDragging
? "none"
: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1)",
}}
>
<div
className="absolute inset-[10%] rounded-full bg-black/12 blur-[20px]"
style={{
transform: `translate3d(0, ${anglePreviewFrameSize.height * 0.31}px, -18px) scale(${0.96 + angleZoom / 240})`,
}}
/>
<div
className="absolute inset-0 rounded-[26px] border border-[#eef0f4] bg-white shadow-[0_16px_34px_rgba(15,23,42,0.08)]"
style={{
transform: `translateZ(${anglePreviewDepth / 2}px)`,
}}
>
<div className="absolute inset-[6px] overflow-hidden rounded-[20px] bg-[#f9fafc]">
<img
alt={displayTitle}
className="h-full w-full object-contain"
draggable={false}
src={anglePreviewImageSrc}
/>
</div>
</div>
<div
className="absolute inset-0 rounded-[26px] border border-[#e5e8ef] bg-[linear-gradient(180deg,#f7f8fb,#e6e9f0)]"
style={{
transform: `translateZ(-${anglePreviewDepth / 2}px)`,
}}
/>
<div
className="absolute left-0 top-[10px] bottom-[10px] rounded-l-[18px] border border-[#dde1e9] bg-[linear-gradient(180deg,#eff2f7,#dde2ea)]"
style={{
width: `${anglePreviewDepth}px`,
transform: `translateX(-${anglePreviewDepth / 2}px) rotateY(-90deg)`,
transformOrigin: "center",
}}
/>
<div
className="absolute right-0 top-[10px] bottom-[10px] rounded-r-[18px] border border-[#dde1e9] bg-[linear-gradient(180deg,#eef1f6,#dde2ea)]"
style={{
width: `${anglePreviewDepth}px`,
transform: `translateX(${anglePreviewDepth / 2}px) rotateY(90deg)`,
transformOrigin: "center",
}}
/>
<div
className="absolute left-[10px] right-[10px] top-0 rounded-t-[16px] border border-[#eef0f4] bg-[linear-gradient(180deg,#ffffff,#edf0f5)]"
style={{
height: `${anglePreviewDepth}px`,
transform: `translateY(-${anglePreviewDepth / 2}px) rotateX(90deg)`,
transformOrigin: "center",
}}
/>
<div
className="absolute left-[10px] right-[10px] bottom-0 rounded-b-[16px] border border-[#dde1ea] bg-[linear-gradient(180deg,#e4e8ef,#d5dbe5)]"
style={{
height: `${anglePreviewDepth}px`,
transform: `translateY(${anglePreviewDepth / 2}px) rotateX(-90deg)`,
transformOrigin: "center",
}}
/>
</div>
) : (
<div
className="relative z-[2]"
style={{
height: `${ANGLE_PANEL_CAMERA_SCENE_SIZE}px`,
transform: `scale(${angleCameraSceneScale})`,
transition: isAnglePreviewDragging
? "none"
: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1)",
width: `${ANGLE_PANEL_CAMERA_SCENE_SIZE}px`,
}}
>
<div
className="absolute left-1/2 top-1/2 z-[2] h-[116px] w-[116px] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-[22px] border border-[#eceff4] bg-white/96 shadow-[0_12px_28px_rgba(15,23,42,0.08)]"
style={{
backdropFilter: "blur(8px)",
}}
>
<div
className="absolute inset-[10px] rounded-[17px] border border-[#eef1f5]"
style={{
backgroundImage:
"linear-gradient(180deg,#fcfcfd,#f5f6f8), linear-gradient(90deg,rgba(227,230,236,0.55) 1px,transparent 1px), linear-gradient(rgba(227,230,236,0.55) 1px,transparent 1px)",
backgroundPosition: "0 0, 0 0, 0 0",
backgroundSize: "100% 100%, 14px 14px, 14px 14px",
backgroundBlendMode: "normal",
}}
>
<img
alt={displayTitle}
className="absolute left-1/2 top-1/2 max-h-[76px] max-w-[76px] -translate-x-1/2 -translate-y-1/2 object-contain"
draggable={false}
src={anglePreviewImageSrc}
/>
</div>
</div>
<div
className="absolute left-1/2 top-1/2 z-[1] h-px bg-[#dfe4ec]"
style={{
width: `${Math.max(40, angleCameraConnection.length - 12)}px`,
transform: `translateY(-0.5px) rotate(${angleCameraConnection.angle}deg)`,
transformOrigin: "0 50%",
}}
/>
<div
className="absolute left-1/2 top-1/2 z-[3]"
style={{
transform: `translate3d(${angleCameraOrbitPosition.x}px, ${angleCameraOrbitPosition.y}px, ${angleCameraOrbitPosition.z}px)`,
transformStyle: "preserve-3d",
transition: isAnglePreviewDragging
? "none"
: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1)",
}}
>
<div
className="relative"
style={{
width: "60px",
height: "60px",
transform:
"translate(-50%, -50%) rotateX(-18deg) rotateY(28deg)",
transformStyle: "preserve-3d",
}}
>
<div
className="absolute inset-0 rounded-[15px] border border-[#e5e9f0] bg-[linear-gradient(180deg,#ffffff,#edf1f5)] shadow-[0_12px_24px_rgba(15,23,42,0.12)]"
style={{ transform: "translateZ(10px)" }}
>
<div className="absolute inset-[10px] rounded-[10px] border border-[#edf1f6] bg-[linear-gradient(180deg,#f9fafc,#eef1f5)]">
<AngleCameraModelGlyph />
</div>
</div>
<div
className="absolute inset-y-[5px] -right-[11px] rounded-r-[12px] border border-[#dde2ea] bg-[linear-gradient(180deg,#eef2f7,#dfe4ec)]"
style={{
width: "22px",
transform: "rotateY(90deg)",
transformOrigin: "left center",
}}
/>
<div
className="absolute inset-x-[5px] -top-[11px] rounded-t-[12px] border border-[#e8ecf2] bg-[linear-gradient(180deg,#ffffff,#edf1f6)]"
style={{
height: "22px",
transform: "rotateX(90deg)",
transformOrigin: "center bottom",
}}
/>
</div>
</div>
</div>
)}
{anglePanelMode === "subject" ? (
<div className="pointer-events-none absolute bottom-[36px] left-[34px] z-[3] flex h-[50px] w-[50px] items-center justify-center rounded-[16px] border border-[#eceef3] bg-white shadow-[0_10px_24px_rgba(15,23,42,0.07)]">
<Box className="h-5 w-5 text-[#8a8f98]" />
</div>
) : null}
</div>
</div>
<div className="mt-5 space-y-4">
<AngleEditPanelSlider
label="旋转"
max={180}
min={-180}
onChange={setAngleHorizontalAngle}
value={angleHorizontalAngle}
valueLabel={String(Math.round(angleHorizontalAngle))}
/>
<AngleEditPanelSlider
label="倾斜"
max={60}
min={-30}
onChange={setAngleVerticalAngle}
value={angleVerticalAngle}
valueLabel={String(Math.round(angleVerticalAngle))}
/>
<AngleEditPanelSlider
label="缩放"
max={100}
min={0}
onChange={setAngleZoom}
value={angleZoom}
valueLabel={formatAnglePanelZoomLabel(angleZoom)}
/>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
className="inline-flex h-[50px] items-center justify-center rounded-[18px] border border-[#dfe2e8] bg-white text-[17px] font-medium text-[#27292f] transition-colors hover:bg-[#f7f8fa]"
disabled={isApplyingAngleEdit}
onClick={() => setAnglePanelOpen(false)}
type="button"
>
取消
</button>
<button
className="group relative inline-flex h-[50px] items-center justify-center gap-2 rounded-[18px] bg-[#2d2f34] text-[17px] font-semibold text-white transition-colors hover:bg-[#23252a] disabled:cursor-not-allowed disabled:bg-[#cfd2d8] disabled:text-white/80"
disabled={isApplyingAngleEdit}
onClick={() => void handleApplyAngleEdit()}
type="button"
>
{isApplyingAngleEdit ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
生成中
</>
) : (
<>
立即使用
<ImageActionCreditBadge
className="ml-0 bg-white/18 text-white"
compact
label={angleEditCostShortLabel}
/>
<Sparkles className="h-4 w-4" />
</>
)}
</button>
</div>
</div>,
document.body
)}
{/* 文字编辑面板(显示在图片节点旁边) */}
{isSelected &&
textEditPanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed z-[9999] w-[360px] rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
style={{
top: getTextPanelPosition().top,
left: getTextPanelPosition().left,
}}
>
<div className="flex items-center justify-between border-border/70 border-b px-4 py-3">
<div className="flex items-center gap-2">
<Type className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">编辑文字</span>
</div>
<Button
className="h-7 w-7"
onClick={() => setTextEditPanelOpen(false)}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3">
<p className="text-muted-foreground text-xs">
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
</p>
{isDetectingText && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在识别图片文字...
</div>
)}
{textEditError && !isDetectingText && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-destructive text-xs">
{textEditError}
</div>
)}
{!isDetectingText &&
textEditItems.length === 0 &&
!textEditError && (
<div className="rounded-md bg-slate-50 px-3 py-3 text-muted-foreground text-xs dark:bg-zinc-900">
未识别到可编辑文字,可点击“重新识别”再试一次。
</div>
)}
{textEditItems.map((item, index) => (
<div
className="space-y-2 rounded-lg border border-border/70 p-3"
key={item.id}
>
<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
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveTextItem(item.id)}
size="icon"
title="删除该条文字"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="line-clamp-2 text-muted-foreground text-xs">
原文:{item.originalText}
</div>
<Input
onChange={(e) =>
handleTextItemChange(item.id, e.target.value)
}
placeholder="输入替换后的文字"
value={item.editedText}
/>
<TextEditColorField
value={item.textColor}
onChange={(value) =>
handleTextItemColorChange(item.id, value)
}
/>
</div>
))}
{deletedTextItems.length > 0 && (
<div className="rounded-md border border-orange-300 border-dashed bg-orange-50 px-3 py-2 text-orange-700 text-xs dark:border-orange-900 dark:bg-orange-950/40 dark:text-orange-200">
已标记删除 {deletedTextItems.length}{" "}
条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 border-border/70 border-t px-4 py-3">
<Button
disabled={isDetectingText || isApplyingTextEdit}
onClick={() => detectTextItems(true)}
size="sm"
variant="outline"
>
{isDetectingText ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
重新识别
</Button>
<Button
disabled={
isDetectingText ||
isApplyingTextEdit ||
!hasTextEditChanges
}
onClick={handleApplyTextEdit}
size="sm"
>
{isApplyingTextEdit ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Check className="mr-1 h-3.5 w-3.5" />
)}
{hasTextEditChanges ? "确认替换" : "先修改文字"}
</Button>
</div>
</div>,
document.body
)}
{/* 图片标注器 */}
{annotatorOpen && data.src && (
<ImageAnnotator
imageUrl={data.src}
onCancel={() => setAnnotatorOpen(false)}
onConfirm={handleAnnotateComplete}
onOpenChange={setAnnotatorOpen}
open={annotatorOpen}
/>
)}
{resizeAnnotatorOpen && data.src && (
<ImageAnnotator
confirmButtonText="保存处理结果"
imageUrl={data.src}
initialResizeMode
key={`resize-${node.id}-${data.src}`}
onCancel={() => setResizeAnnotatorOpen(false)}
onConfirm={handleResizeEditorComplete}
onOpenChange={setResizeAnnotatorOpen}
open={resizeAnnotatorOpen}
/>
)}
{/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen &&
data.src &&
createPortal(
<div
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
onClick={() => setFullscreenPreviewOpen(false)}
>
{/* 关闭按钮 */}
<button
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
onClick={() => setFullscreenPreviewOpen(false)}
>
<svg
className="h-6 w-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</button>
<button
className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={isDownloadingOriginalImage}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownload();
}}
title="下载原图"
type="button"
>
{isDownloadingOriginalImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
下载原图
</button>
{/* 图片 */}
<img
alt={data.prompt || "图片预览"}
className="max-h-[95vh] max-w-[95vw] object-contain"
decoding="async"
draggable={false}
onClick={(e) => e.stopPropagation()}
src={fullscreenPreviewSrc}
style={activeImageAdjustmentsStyle}
/>
{/* 底部提示 */}
<div className="-translate-x-1/2 absolute bottom-4 left-1/2 text-sm text-white/60">
点击任意位置关闭
</div>
</div>,
document.body
)}
<CanvasImageDownloadProgressDialog
onCancel={handleCancelDownload}
progress={downloadProgress}
/>
</>
);
}