Your Name 4c86e01956
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add reference images to local edit regions
2026-05-20 11:01:36 +08:00

10353 lines
352 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 {
AlertCircle,
Blend,
Box,
Camera,
CircleDot,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Crosshair,
Crop,
Download,
Eraser,
Expand,
FlipHorizontal2,
FlipVertical2,
Globe2,
ImagePlus,
Layers,
Loader2,
Link2,
Maximize2,
MessageSquarePlus,
MoreHorizontal,
MoreVertical,
Pencil,
RefreshCw,
RotateCcw,
Scan,
Scissors,
SlidersHorizontal,
Sparkles,
StickyNote,
Trash2,
Type,
Unlink2,
Wand2,
X,
} 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 { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { ImageAnnotator } from "@/components/image-annotator";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
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 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 { inferOriginalImageUrl } from "@/lib/image-url";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
isArchivedWorldLabsAssetUrl,
pickBestArchivedWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils";
import {
getNodeContentScale,
getScaledNodeContentStyle,
} from "@/lib/canvas/node-content-scale";
import {
getCanvasToolbarItem,
isCanvasToolbarItemEnabled,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
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 LayerSplitBounds {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
}
type AngleEditPanelMode = "subject" | "camera";
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects";
type CropPanelGroupKey =
| "common"
| "instagram"
| "facebook"
| "tiktok"
| "youtube";
type CropInteractionHandle =
| "move"
| "n"
| "s"
| "e"
| "w"
| "nw"
| "ne"
| "sw"
| "se";
type ImageAdjustmentState = {
light: number;
exposure: number;
contrast: number;
highlights: number;
shadows: number;
whites: number;
blacks: number;
vibrance: number;
saturation: number;
temperature: number;
tint: number;
sharpen: number;
clarity: number;
grain: number;
vignette: number;
softLight: number;
glow: number;
};
type ImageAdjustmentKey = keyof ImageAdjustmentState;
type CropPresetOption = {
id: string;
label: string;
ratio: number;
};
type CropPresetGroup = {
key: CropPanelGroupKey;
label: string;
options: CropPresetOption[];
};
type CropInteractionState = {
handle: CropInteractionHandle;
pointerId: number;
startPoint: BoxCutoutPoint;
startRect: NormalizedCropRect;
};
type LocalEditRegionInteractionState = {
handle: CropInteractionHandle;
pointerId: number;
regionId: string;
startPoint: BoxCutoutPoint;
startRect: NormalizedCropRect;
};
type AnglePreviewDragState = {
pointerId: number;
startX: number;
startY: number;
startHorizontalAngle: number;
startVerticalAngle: number;
};
interface BoxCutoutPoint {
x: number;
y: number;
}
interface LocalEditReferenceImage {
id: string;
url: string;
name?: string;
source?: "upload" | "canvas";
createdAt: number;
}
interface LocalEditRegion {
id: string;
index: number;
color: string;
prompt: string;
rect: NormalizedCropRect;
referenceImages?: LocalEditReferenceImage[];
createdAt: number;
}
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
const BOX_CUTOUT_MIN_EDGE_PX = 24;
const LOCAL_EDIT_METADATA_KEY = "localEditRegions";
const LOCAL_EDIT_MAX_REGIONS = 8;
const LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION = 3;
const LOCAL_EDIT_MIN_EDGE_PX = BOX_CUTOUT_MIN_EDGE_PX;
const LOCAL_EDIT_POLL_INTERVAL_MS = 2500;
const LOCAL_EDIT_MAX_POLL_ATTEMPTS = 180;
const LOCAL_EDIT_COLORS = [
"#ef4444",
"#2563eb",
"#16a34a",
"#f97316",
"#a855f7",
"#06b6d4",
"#eab308",
"#ec4899",
];
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 DEFAULT_CROP_PRESET_ID = "1:1";
const IMAGE_ADJUST_NOISE_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.18' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(#n)' opacity='1'/></svg>"
)}`;
const DEFAULT_IMAGE_ADJUSTMENTS: ImageAdjustmentState = {
light: 0,
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
vibrance: 0,
saturation: 0,
temperature: 0,
tint: 0,
sharpen: 0,
clarity: 0,
grain: 0,
vignette: 0,
softLight: 0,
glow: 0,
};
const IMAGE_ADJUSTMENT_KEYS = Object.keys(
DEFAULT_IMAGE_ADJUSTMENTS
) as ImageAdjustmentKey[];
const CROP_PRESET_GROUPS: CropPresetGroup[] = [
{
key: "common",
label: "通用",
options: [
{ id: "1:1", label: "1:1", ratio: 1 },
{ id: "3:4", label: "3:4", ratio: 3 / 4 },
{ id: "2:3", label: "2:3", ratio: 2 / 3 },
{ id: "9:16", label: "9:16", ratio: 9 / 16 },
{ id: "4:3", label: "4:3", ratio: 4 / 3 },
{ id: "3:2", label: "3:2", ratio: 3 / 2 },
{ id: "16:9", label: "16:9", ratio: 16 / 9 },
],
},
{
key: "instagram",
label: "Instagram",
options: [
{ id: "ig-square", label: "1:1", ratio: 1 },
{ id: "ig-portrait", label: "4:5", ratio: 4 / 5 },
{ id: "ig-story", label: "9:16", ratio: 9 / 16 },
],
},
{
key: "facebook",
label: "Facebook",
options: [
{ id: "fb-feed", label: "4:5", ratio: 4 / 5 },
{ id: "fb-cover", label: "16:9", ratio: 16 / 9 },
],
},
{
key: "tiktok",
label: "TikTok",
options: [{ id: "tt-video", label: "9:16", ratio: 9 / 16 }],
},
{
key: "youtube",
label: "YouTube",
options: [
{ id: "yt-cover", label: "16:9", ratio: 16 / 9 },
{ id: "yt-short", label: "9:16", ratio: 9 / 16 },
],
},
];
function normalizeImageAdjustments(value: unknown): ImageAdjustmentState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Partial<Record<ImageAdjustmentKey, unknown>>)
: {};
return {
light: clampNumber(Number(raw.light ?? 0), -100, 100),
exposure: clampNumber(Number(raw.exposure ?? 0), -100, 100),
contrast: clampNumber(Number(raw.contrast ?? 0), -100, 100),
highlights: clampNumber(Number(raw.highlights ?? 0), -100, 100),
shadows: clampNumber(Number(raw.shadows ?? 0), -100, 100),
whites: clampNumber(Number(raw.whites ?? 0), -100, 100),
blacks: clampNumber(Number(raw.blacks ?? 0), -100, 100),
vibrance: clampNumber(Number(raw.vibrance ?? 0), -100, 100),
saturation: clampNumber(Number(raw.saturation ?? 0), -100, 100),
temperature: clampNumber(Number(raw.temperature ?? 0), -100, 100),
tint: clampNumber(Number(raw.tint ?? 0), -100, 100),
sharpen: clampNumber(Number(raw.sharpen ?? 0), 0, 100),
clarity: clampNumber(Number(raw.clarity ?? 0), 0, 100),
grain: clampNumber(Number(raw.grain ?? 0), 0, 100),
vignette: clampNumber(Number(raw.vignette ?? 0), 0, 100),
softLight: clampNumber(Number(raw.softLight ?? 0), 0, 100),
glow: clampNumber(Number(raw.glow ?? 0), 0, 100),
};
}
function areImageAdjustmentsEqual(
left: ImageAdjustmentState,
right: ImageAdjustmentState
) {
return IMAGE_ADJUSTMENT_KEYS.every((key) => left[key] === right[key]);
}
function isDefaultImageAdjustments(value: ImageAdjustmentState) {
return areImageAdjustmentsEqual(value, DEFAULT_IMAGE_ADJUSTMENTS);
}
function buildImageAdjustmentFilterStyle(
adjustments: ImageAdjustmentState
): React.CSSProperties {
const brightnessLift =
adjustments.light * 0.34 +
adjustments.exposure * 0.52 +
adjustments.shadows * 0.2 +
adjustments.whites * 0.12 -
adjustments.blacks * 0.16 -
adjustments.highlights * 0.06;
const brightness = clampNumber(1 + brightnessLift / 100, 0.35, 2.2);
const contrastBoost =
adjustments.contrast * 0.78 +
adjustments.clarity * 0.18 +
adjustments.sharpen * 0.1 +
adjustments.highlights * 0.06 -
adjustments.shadows * 0.04;
const contrast = clampNumber(1 + contrastBoost / 100, 0.45, 2.1);
const saturationBoost =
adjustments.saturation * 0.9 +
adjustments.vibrance * 0.55 +
adjustments.sharpen * 0.04;
const saturate = clampNumber(1 + saturationBoost / 100, 0.1, 2.4);
const hueRotate = clampNumber(adjustments.tint * 0.35, -36, 36);
const sepia = clampNumber(
adjustments.temperature > 0 ? adjustments.temperature * 0.18 : 0,
0,
22
);
return {
filter: [
`brightness(${brightness.toFixed(3)})`,
`contrast(${contrast.toFixed(3)})`,
`saturate(${saturate.toFixed(3)})`,
`hue-rotate(${hueRotate.toFixed(1)}deg)`,
`sepia(${sepia.toFixed(1)}%)`,
].join(" "),
willChange: "filter",
};
}
function ImageAdjustPanelSlider({
disabled = false,
label,
max,
min,
onChange,
trackClassName = "h-[4px] bg-[#e5e5e5]",
value,
}: {
disabled?: boolean;
label: string;
max: number;
min: number;
onChange: (value: number) => void;
trackClassName?: string;
value: number;
}) {
return (
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-4">
<span className="text-[18px] font-medium tracking-[0] text-[#1f232a]">
{label}
</span>
<span className="text-[18px] font-medium tabular-nums tracking-[0] text-[#6f747c]">
{Math.round(value)}
</span>
</div>
<Slider
className="w-full"
disabled={disabled}
max={max}
min={min}
onValueChange={([nextValue]) => onChange(nextValue ?? value)}
rangeClassName="bg-transparent"
thumbClassName="h-5 w-5 border-[5px] border-[#2d2f33] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.18)]"
trackClassName={trackClassName}
value={[value]}
/>
</div>
);
}
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 createNormalizedRect = (
start: BoxCutoutPoint,
end: BoxCutoutPoint
): NormalizedCropRect => {
const x = Math.min(start.x, end.x);
const y = Math.min(start.y, end.y);
const right = Math.max(start.x, end.x);
const bottom = Math.max(start.y, end.y);
return {
x,
y,
width: clampNumber(right - x, 0, 1),
height: clampNumber(bottom - y, 0, 1),
};
};
const getCropPresetById = (presetId: string) => {
for (const group of CROP_PRESET_GROUPS) {
const matched = group.options.find((option) => option.id === presetId);
if (matched) return matched;
}
return null;
};
const getNormalizedMinCropSize = (
naturalWidth: number,
naturalHeight: number
) => ({
width: naturalWidth > 0 ? Math.min(1, BOX_CUTOUT_MIN_EDGE_PX / naturalWidth) : 0.02,
height:
naturalHeight > 0 ? Math.min(1, BOX_CUTOUT_MIN_EDGE_PX / naturalHeight) : 0.02,
});
const createCenteredCropRectForAspect = (
ratio: number,
centerX = 0.5,
centerY = 0.5
): NormalizedCropRect => {
const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
const maxHalfWidth = Math.min(centerX, 1 - centerX);
const maxHalfHeight = Math.min(centerY, 1 - centerY);
let width = maxHalfHeight * 2 * safeRatio;
let height = width / safeRatio;
if (width > maxHalfWidth * 2) {
width = maxHalfWidth * 2;
height = width / safeRatio;
}
if (height > maxHalfHeight * 2) {
height = maxHalfHeight * 2;
width = height * safeRatio;
}
return {
x: clampNumber(centerX - width / 2, 0, 1 - width),
y: clampNumber(centerY - height / 2, 0, 1 - height),
width: clampNumber(width, 0, 1),
height: clampNumber(height, 0, 1),
};
};
const createCenteredCropRectFromSize = (
centerX: number,
centerY: number,
width: number,
height: number
): NormalizedCropRect => {
const safeWidth = clampNumber(width, 0.0001, 1);
const safeHeight = clampNumber(height, 0.0001, 1);
let x = centerX - safeWidth / 2;
let y = centerY - safeHeight / 2;
x = clampNumber(x, 0, 1 - safeWidth);
y = clampNumber(y, 0, 1 - safeHeight);
return {
x,
y,
width: safeWidth,
height: safeHeight,
};
};
const updateCropRectWithPointer = ({
aspectRatio,
handle,
minHeight,
minWidth,
pointer,
startPoint,
startRect,
}: {
aspectRatio: number | null;
handle: CropInteractionHandle;
minHeight: number;
minWidth: number;
pointer: BoxCutoutPoint;
startPoint: BoxCutoutPoint;
startRect: NormalizedCropRect;
}): NormalizedCropRect => {
const startRight = startRect.x + startRect.width;
const startBottom = startRect.y + startRect.height;
const currentAspect =
aspectRatio && Number.isFinite(aspectRatio) && aspectRatio > 0
? aspectRatio
: null;
if (handle === "move") {
const nextX = clampNumber(
startRect.x + (pointer.x - startPoint.x),
0,
1 - startRect.width
);
const nextY = clampNumber(
startRect.y + (pointer.y - startPoint.y),
0,
1 - startRect.height
);
return {
x: nextX,
y: nextY,
width: startRect.width,
height: startRect.height,
};
}
if (!currentAspect) {
let x = startRect.x;
let y = startRect.y;
let width = startRect.width;
let height = startRect.height;
if (handle.includes("w")) {
x = clampNumber(pointer.x, 0, startRight - minWidth);
width = startRight - x;
}
if (handle.includes("e")) {
width = clampNumber(pointer.x - startRect.x, minWidth, 1 - startRect.x);
}
if (handle.includes("n")) {
y = clampNumber(pointer.y, 0, startBottom - minHeight);
height = startBottom - y;
}
if (handle.includes("s")) {
height = clampNumber(pointer.y - startRect.y, minHeight, 1 - startRect.y);
}
return {
x,
y,
width,
height,
};
}
if (handle === "n" || handle === "s") {
const anchorY = handle === "n" ? startBottom : startRect.y;
let height = Math.abs(anchorY - pointer.y);
height = clampNumber(
height,
minHeight,
handle === "n" ? anchorY : 1 - anchorY
);
let width = height * currentAspect;
const centerX = startRect.x + startRect.width / 2;
const maxWidth = Math.min(centerX, 1 - centerX) * 2;
if (width > maxWidth) {
width = maxWidth;
height = width / currentAspect;
}
return {
x: clampNumber(centerX - width / 2, 0, 1 - width),
y: handle === "n" ? anchorY - height : anchorY,
width,
height,
};
}
if (handle === "w" || handle === "e") {
const anchorX = handle === "w" ? startRight : startRect.x;
let width = Math.abs(anchorX - pointer.x);
width = clampNumber(width, minWidth, handle === "w" ? anchorX : 1 - anchorX);
let height = width / currentAspect;
const centerY = startRect.y + startRect.height / 2;
const maxHeight = Math.min(centerY, 1 - centerY) * 2;
if (height > maxHeight) {
height = maxHeight;
width = height * currentAspect;
}
return {
x: handle === "w" ? anchorX - width : anchorX,
y: clampNumber(centerY - height / 2, 0, 1 - height),
width,
height,
};
}
const isWest = handle.includes("w");
const isNorth = handle.includes("n");
const anchorX = isWest ? startRight : startRect.x;
const anchorY = isNorth ? startBottom : startRect.y;
const maxWidth = isWest ? anchorX : 1 - anchorX;
const maxHeight = isNorth ? anchorY : 1 - anchorY;
let width = Math.abs(anchorX - pointer.x);
let height = width / currentAspect;
if (height > maxHeight) {
height = maxHeight;
width = height * currentAspect;
}
if (width > maxWidth) {
width = maxWidth;
height = width / currentAspect;
}
if (width < minWidth) {
width = minWidth;
height = width / currentAspect;
}
if (height < minHeight) {
height = minHeight;
width = height * currentAspect;
}
if (height > maxHeight) {
height = maxHeight;
width = height * currentAspect;
}
if (width > maxWidth) {
width = maxWidth;
height = width / currentAspect;
}
return {
x: isWest ? anchorX - width : anchorX,
y: isNorth ? anchorY - height : anchorY,
width,
height,
};
};
const normalizeTextEditItems = (
items: ImageTextEditItem[] | undefined
): ImageTextEditItem[] => {
if (!items || items.length === 0) return [];
return items
.map((item, index) => {
const originalText = (item.originalText || "").trim();
if (!originalText) return null;
return {
id: item.id || String(index + 1),
originalText,
editedText: (item.editedText || item.originalText || "").trim(),
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
};
const createLocalEditRegionId = () =>
`local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const createLocalEditReferenceImageId = () =>
`local-edit-ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => {
if (!value || typeof value !== "object") return null;
const rect = value as Partial<NormalizedCropRect>;
const x = clampNumber(Number(rect.x), 0, 1);
const y = clampNumber(Number(rect.y), 0, 1);
const width = clampNumber(Number(rect.width), 0, 1 - x);
const height = clampNumber(Number(rect.height), 0, 1 - y);
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return null;
}
return { x, y, width, height };
};
const normalizeLocalEditReferenceImages = (
value: unknown
): LocalEditReferenceImage[] => {
if (!Array.isArray(value)) return [];
const references: LocalEditReferenceImage[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
const url = typeof record.url === "string" ? record.url.trim() : "";
if (!isHttpImageUrl(url)) continue;
const name =
typeof record.name === "string" && record.name.trim()
? record.name.trim().slice(0, 80)
: "";
references.push({
id:
typeof record.id === "string" && record.id.trim()
? record.id.trim()
: createLocalEditReferenceImageId(),
url,
...(name ? { name } : {}),
source: record.source === "canvas" ? "canvas" : "upload",
createdAt:
typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
? record.createdAt
: Date.now(),
});
if (references.length >= LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION) {
break;
}
}
return references;
};
const reindexLocalEditRegions = (
regions: LocalEditRegion[]
): LocalEditRegion[] =>
regions.map((region, index) => ({
...region,
index: index + 1,
color: region.color || LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length],
}));
const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] => {
if (!Array.isArray(value)) return [];
const regions: LocalEditRegion[] = [];
value.forEach((item, index) => {
if (!item || typeof item !== "object") return;
const record = item as Record<string, unknown>;
const rect = normalizeLocalEditRect(record.rect);
if (!rect) return;
regions.push({
id:
typeof record.id === "string" && record.id.trim()
? record.id.trim()
: createLocalEditRegionId(),
index:
typeof record.index === "number" && Number.isFinite(record.index)
? record.index
: index + 1,
color:
typeof record.color === "string" && record.color.trim()
? record.color.trim()
: LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length],
prompt:
typeof record.prompt === "string" ? record.prompt.trim() : "",
rect,
referenceImages: normalizeLocalEditReferenceImages(record.referenceImages),
createdAt:
typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
? record.createdAt
: Date.now(),
});
});
return reindexLocalEditRegions(regions.slice(0, LOCAL_EDIT_MAX_REGIONS));
};
const getLocalEditRegionsFromMetadata = (
metadata: Record<string, any> | undefined
) => normalizeLocalEditRegions(metadata?.[LOCAL_EDIT_METADATA_KEY]);
const getNormalizedRectOverlayStyle = (
rect: NormalizedCropRect,
metrics: ImageDisplayMetrics
): ImageOverlayRectStyle => ({
left: metrics.offsetX + rect.x * metrics.renderedWidth,
top: metrics.offsetY + rect.y * metrics.renderedHeight,
width: rect.width * metrics.renderedWidth,
height: rect.height * metrics.renderedHeight,
});
const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url);
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 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));
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));
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);
}
}
function inferModelFormat(modelUrl?: string | null) {
const value = String(modelUrl || "").toLowerCase();
if (!value) return "glb";
if (value.includes(".gltf")) return "gltf";
if (value.includes(".glb")) return "glb";
if (value.includes(".usdz")) return "usdz";
if (value.includes(".fbx")) return "fbx";
if (value.includes(".obj")) return "obj";
if (value.includes(".stl")) return "stl";
if (value.includes(".3mf")) return "3mf";
return "glb";
}
function inferTripoImageType(imageUrl?: string | null) {
const value = String(imageUrl || "").toLowerCase();
if (value.includes(".png")) return "png";
if (value.includes(".webp")) return "webp";
return "jpg";
}
const normalizeMediaUrl = (url: string): string => {
const raw = String(url || "").trim();
if (!raw) return "";
try {
const parsed = new URL(raw);
return `${parsed.origin}${parsed.pathname}`
.replace(/\/+$/, "")
.toLowerCase();
} catch {
return raw.split("#")[0].split("?")[0].replace(/\/+$/, "").toLowerCase();
}
};
const normalizeImageIdentityUrl = (url: string): string => {
const raw = String(url || "").trim();
if (!raw) return "";
const canonical = inferOriginalImageUrl(raw);
return normalizeMediaUrl(canonical || raw);
};
const normalizeImageTitle = (raw?: string) => {
const title = (raw || "").trim();
return title || "";
};
const getImageNodeTitle = (data: ImageNodeData, nodeId: string) => {
return (
normalizeImageTitle(data.title) ||
normalizeImageTitle(data.prompt) ||
`图片-${nodeId.slice(0, 6)}`
);
};
const sanitizeFileName = (name: string) => {
const safe = name
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
return safe || "image";
};
const triggerBrowserDownload = (
url: string,
filename?: string,
options?: { openInNewTab?: boolean }
) => {
const link = document.createElement("a");
link.href = url;
if (filename) {
link.download = filename;
}
if (options?.openInNewTab) {
link.target = "_blank";
}
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const GENERIC_CANVAS_PROMPTS = new Set([
"对话生成",
"图片生成",
"AI生成",
"智能生成",
"自动生成",
]);
const normalizePromptText = (value: unknown): string => {
return typeof value === "string" ? value.trim() : "";
};
const resolveRegeneratePromptSeed = (data: ImageNodeData): string => {
const metadataPrompt = normalizePromptText(data.metadata?.generationPrompt);
if (metadataPrompt) return metadataPrompt;
const nodePrompt = normalizePromptText(data.prompt);
if (nodePrompt && !GENERIC_CANVAS_PROMPTS.has(nodePrompt)) {
return nodePrompt;
}
return nodePrompt;
};
const COMMON_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
] as const;
function guessNearestAspectRatio(width: number, height: number): string {
if (!width || !height) return "auto";
const target = width / height;
let best = "auto";
let minDiff = Number.POSITIVE_INFINITY;
for (const ratio of COMMON_ASPECT_RATIOS) {
const [w, h] = ratio.split(":").map(Number);
const value = w / h;
const diff = Math.abs(target - value);
if (diff < minDiff) {
minDiff = diff;
best = ratio;
}
}
return best;
}
/**
* 在图片上绘制标记点,返回带标记的图片 base64
*/
async function drawMarkerOnImage(
imageUrl: string,
clickX: number,
clickY: number
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
const loadUrl = buildImageProxyUrl(imageUrl, { 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;
});
}
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 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 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;
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 hasHoverImageMorePrimaryActions =
imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled ||
imageEnhanceEnabled ||
imageNoteEnabled ||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
imageResizeEnabled ||
imageMultiAngleEnabled ||
imagePromptRegenerateEnabled ||
imageTextEditEnabled ||
imageExtendEnabled ||
imageCropEnabled ||
imageAdjustEnabled ||
imageFlipRotateEnabled ||
imageRenameEnabled;
const hasHoverImageMoreSecondaryActions =
imageDownloadEnabled || imageDeleteEnabled;
const hasHoverImageMoreMenu =
imageNodeMoreEnabled &&
(hasHoverImageMorePrimaryActions || hasHoverImageMoreSecondaryActions);
const hasSelectedImageMorePrimaryActions =
imageExtendEnabled ||
imageCropEnabled ||
imageAdjustEnabled ||
imageFlipRotateEnabled ||
imagePromptRegenerateEnabled ||
imageNoteEnabled;
const hasSelectedImageMoreMenu =
imageNodeMoreEnabled &&
(hasSelectedImageMorePrimaryActions || imageDeleteEnabled);
const hasSelectedImagePrimaryToolbarActions =
imagePickObjectEnabled ||
imageEnhanceEnabled ||
imageRemoveBackgroundEnabled ||
imageBoxCutoutEnabled ||
imageLocalEditEnabled ||
imageRemoveWatermarkEnabled ||
imageResizeEnabled ||
imageTextEditEnabled ||
imageMultiAngleEnabled ||
imageGenerate3dModelEnabled ||
imageGenerate3dWorldEnabled ||
imageLayerSplitEnabled;
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 scaledContentStyle = useMemo(
() => getScaledNodeContentStyle(nodeContentScale, { allowUpscale: true }),
[nodeContentScale]
);
const selectedTitleStyle = useMemo<React.CSSProperties>(
() => ({
top: `${-28 * nodeContentScale}px`,
transform: `scale(${nodeContentScale})`,
transformOrigin: "bottom left",
width: `${100 / nodeContentScale}%`,
}),
[nodeContentScale]
);
const detailProgressLabel =
typeof data.metadata?.detailProgressLabel === "string"
? data.metadata.detailProgressLabel.trim()
: "";
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 [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 [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
const [notePanelOpen, setNotePanelOpen] = useState(false);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const [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 [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 [localEditRegions, setLocalEditRegions] = useState<LocalEditRegion[]>(
() => getLocalEditRegionsFromMetadata(data.metadata)
);
const [isLocalEditDrawing, setIsLocalEditDrawing] = useState(false);
const [localEditDragStart, setLocalEditDragStart] =
useState<BoxCutoutPoint | null>(null);
const [localEditDragEnd, setLocalEditDragEnd] =
useState<BoxCutoutPoint | 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
);
useEffect(() => {
if (!batchProcessingEnabled) {
setSceneFusionDialogOpen(false);
}
}, [batchProcessingEnabled]);
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 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 localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
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);
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 isPickerMode = useCanvasStore((state) => state.isPickerMode);
const pickerTargetNodeId = useCanvasStore(
(state) => state.pickerTargetNodeId
);
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
const addPendingPickedObject = useCanvasStore(
(state) => state.addPendingPickedObject
);
// 当前节点是否处于拾取模式
const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id;
// 当前正在识别的点击位置
const [currentClick, setCurrentClick] = useState<{
x: number;
y: number;
} | null>(null);
const displayTitle = getImageNodeTitle(data, node.id);
const agentExecutionSnapshotId =
typeof data.metadata?.agentExecutionSnapshotId === "string"
? data.metadata.agentExecutionSnapshotId.trim()
: "";
const detailUiScreenOverlay = useMemo(() => {
const raw = data.metadata?.detailUiScreenOverlay;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as DetailUiScreenOverlaySnapshot;
}, [data.metadata]);
const detailSubjectAnalysis = useMemo(() => {
return normalizeDetailUiSubjectAnalysis(
data.metadata?.detailSubjectAnalysis
);
}, [data.metadata]);
const detailUiDslDocumentFromMetadata = useMemo(() => {
const raw = data.metadata?.detailUiDslDocument;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as DetailUiDslV2Document;
}, [data.metadata]);
const detailUiDslDocument = useMemo(() => {
if (detailUiDslDocumentFromMetadata) return detailUiDslDocumentFromMetadata;
if (!detailUiScreenOverlay) return null;
return buildDetailUiDslV2({
overlay: detailUiScreenOverlay,
subjectAnalysis: detailSubjectAnalysis,
});
}, [
detailSubjectAnalysis,
detailUiDslDocumentFromMetadata,
detailUiScreenOverlay,
]);
const shouldRenderDetailOverlay =
data.metadata?.detailGenerationMode === "agent_layout" &&
Boolean(detailUiScreenOverlay);
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
? agentExecutionSnapshotId.slice(0, 8)
: "";
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
const compareSource = useMemo(() => {
const targetIdentity = normalizeImageIdentityUrl(data.src || "");
if (!targetIdentity) return "";
const candidates: string[] = [];
const pushCandidate = (value: unknown) => {
const raw = String(value || "").trim();
if (!raw) return;
candidates.push(raw);
};
const visited = new Set<string>([node.id]);
const queue = Array.isArray(node.connections) ? [...node.connections] : [];
let safety = 0;
while (queue.length > 0 && safety < 120) {
safety += 1;
const sourceNodeId = queue.shift();
if (!sourceNodeId || visited.has(sourceNodeId)) continue;
visited.add(sourceNodeId);
const sourceNode = allNodes.find((item) => item.id === sourceNodeId);
if (!sourceNode || sourceNode.type !== "image") continue;
const sourceData = (sourceNode.data as any) || {};
pushCandidate(sourceData.src);
pushCandidate(sourceData.originalSrc);
pushCandidate(sourceData.metadata?.compareSourceUrl);
if (
Array.isArray(sourceNode.connections) &&
sourceNode.connections.length > 0
) {
queue.push(...sourceNode.connections);
}
}
pushCandidate(data.originalSrc);
pushCandidate(data.metadata?.compareSourceUrl);
for (const candidate of candidates) {
const identity = normalizeImageIdentityUrl(candidate);
if (!identity) continue;
if (identity !== targetIdentity) {
return candidate;
}
}
return "";
}, [
allNodes,
data.metadata,
data.originalSrc,
data.src,
node.connections,
node.id,
]);
const hasCompareSource = Boolean(data.src) && Boolean(compareSource);
const compareButtonVisualSize = useMemo(() => {
const minSide = Math.max(
1,
Math.min(node.size.width || 0, node.size.height || 0)
);
return Math.round(Math.max(24, Math.min(32, minSide * 0.09)));
}, [node.size.height, node.size.width]);
const compareButtonHitSize = useMemo(
() => Math.max(34, compareButtonVisualSize + 8),
[compareButtonVisualSize]
);
const compareButtonIconSize = useMemo(
() => Math.round(Math.max(12, Math.min(16, compareButtonVisualSize * 0.5))),
[compareButtonVisualSize]
);
const compareButtonInset = useMemo(
() => Math.max(6, Math.min(10, Math.round(compareButtonVisualSize * 0.22))),
[compareButtonVisualSize]
);
const displayImageWidth = useMemo(
() =>
Math.max(640, Math.min(2200, Math.round((node.size.width || 320) * 2))),
[node.size.width]
);
const displayImageSrc = useMemo(
() =>
buildImageProxyUrl(data.src, {
width: displayImageWidth,
quality: 86,
format: "webp",
}),
[data.src, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = inferOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 2400,
quality: 90,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
width: displayImageWidth,
quality: 86,
format: "webp",
}),
[compareSource, 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 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 localEditPromptPortalOverlays = useMemo(() => {
if (
!localEditMode ||
!boxCutoutMetrics ||
typeof window === "undefined" ||
!containerRef.current
) {
return [];
}
const nodeRect = containerRef.current.getBoundingClientRect();
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);
return {
region,
isEditing: editingLocalEditRegionId === region.id || !region.prompt.trim(),
style: {
left: nodeRect.left + left * nodeContentScale,
top: nodeRect.top + Math.max(4, promptTop) * nodeContentScale,
width: promptWidth,
transform: `scale(${nodeContentScale})`,
transformOrigin: "top left",
} satisfies React.CSSProperties,
};
});
}, [
boxCutoutMetrics,
editingLocalEditRegionId,
localEditMode,
localEditRegionOverlays,
nodeContentScale,
]);
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);
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(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)
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
localEditPromptDraftRefs.current = Object.fromEntries(
nextRegions.map((region) => [region.id, region.prompt])
);
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];
}
updateNode(node.id, {
data: {
...data,
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
},
});
},
[data, node.id, updateNode]
);
const refreshBoxCutoutMetrics = useCallback(() => {
const imageElement = displayImageRef.current;
if (!imageElement) {
setBoxCutoutMetrics(null);
return;
}
const naturalWidth =
imageElement.naturalWidth ||
metadataResolution?.width ||
loadedResolution?.width ||
0;
const naturalHeight =
imageElement.naturalHeight ||
metadataResolution?.height ||
loadedResolution?.height ||
0;
const boxWidth = imageElement.clientWidth;
const boxHeight = imageElement.clientHeight;
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
setBoxCutoutMetrics(null);
return;
}
const displayScale = Math.min(
boxWidth / naturalWidth,
boxHeight / naturalHeight
);
const renderedWidth = naturalWidth * displayScale;
const renderedHeight = naturalHeight * displayScale;
setBoxCutoutMetrics({
offsetX: (boxWidth - renderedWidth) / 2,
offsetY: (boxHeight - renderedHeight) / 2,
renderedWidth,
renderedHeight,
naturalWidth,
naturalHeight,
});
}, [loadedResolution, metadataResolution]);
const getBoxCutoutPointerPoint = useCallback(
(clientX: number, clientY: number) => {
const imageElement = displayImageRef.current;
if (!imageElement) return null;
const currentMetrics = (() => {
if (
boxCutoutMetrics &&
boxCutoutMetrics.renderedWidth > 0 &&
boxCutoutMetrics.renderedHeight > 0
) {
return boxCutoutMetrics;
}
const naturalWidth =
imageElement.naturalWidth ||
metadataResolution?.width ||
loadedResolution?.width ||
0;
const naturalHeight =
imageElement.naturalHeight ||
metadataResolution?.height ||
loadedResolution?.height ||
0;
const boxWidth = imageElement.clientWidth;
const boxHeight = imageElement.clientHeight;
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
return null;
}
const displayScale = Math.min(
boxWidth / naturalWidth,
boxHeight / naturalHeight
);
return {
offsetX: (boxWidth - naturalWidth * displayScale) / 2,
offsetY: (boxHeight - naturalHeight * displayScale) / 2,
renderedWidth: naturalWidth * displayScale,
renderedHeight: naturalHeight * displayScale,
naturalWidth,
naturalHeight,
} satisfies ImageDisplayMetrics;
})();
if (!currentMetrics) return null;
const rect = imageElement.getBoundingClientRect();
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;
setLocalEditRegions(getLocalEditRegionsFromMetadata(data.metadata));
}, [data.metadata, data.src, localEditMode]);
useEffect(() => {
if (!isSelected) {
if (localEditMode && isLocalEditControlTarget(document.activeElement)) {
return;
}
setCropPanelOpen(false);
setCropRect(null);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setIsEditingTitle(false);
setPromptDialogOpen(false);
resetBoxCutoutState();
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) => {
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));
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setIsLocalEditDrawing(false);
setLocalEditMode(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
toast.info("拖拽框选要局部编辑的位置,并在框下方填写提示词");
},
[
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();
if (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);
setLocalEditDragStart(pointerResult.point);
setLocalEditDragEnd(pointerResult.point);
setIsLocalEditDrawing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
getBoxCutoutPointerPoint,
isProcessing,
isSubmittingLocalEdit,
localEditMode,
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);
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
) {
const nextRegion: LocalEditRegion = {
id: createLocalEditRegionId(),
index: localEditRegions.length + 1,
color:
LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length],
prompt: "",
rect: nextRect,
referenceImages: [],
createdAt: Date.now(),
};
commitLocalEditRegions([...localEditRegions, nextRegion]);
setEditingLocalEditRegionId(nextRegion.id);
} else {
toast.info("框选区域太小,请至少拖出 24px 以上的区域");
}
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setIsLocalEditDrawing(false);
},
[
commitLocalEditRegions,
getBoxCutoutPointerPoint,
localEditDragStart,
localEditMode,
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);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[localEditMode]
);
const updateLocalEditRegionRect = useCallback(
(regionId: string, rect: NormalizedCropRect, shouldCommit = false) => {
const nextRegions = reindexLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId ? { ...region, rect } : region
)
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
if (shouldCommit) {
commitLocalEditRegions(nextRegions);
}
},
[commitLocalEditRegions]
);
const handleLocalEditRegionPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>, region: LocalEditRegion) => {
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,
startPoint: pointerResult.point,
startRect: 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);
},
[
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) => {
localEditPromptDraftRefs.current[regionId] = prompt;
commitLocalEditRegions(
localEditRegions.map((region) =>
region.id === regionId ? { ...region, prompt } : region
)
);
},
[commitLocalEditRegions, localEditRegions]
);
const deleteLocalEditRegion = useCallback(
(regionId: string) => {
delete localEditPromptDraftRefs.current[regionId];
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 = {};
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 validRegions = reindexLocalEditRegions(
localEditRegions.map((region) => ({
...region,
prompt: (
localEditPromptDraftRefs.current[region.id] ?? region.prompt
).trim(),
}))
);
if (validRegions.length === 0) {
toast.error("请先框选至少一个局部编辑区域");
return;
}
const emptyRegion = validRegions.find((region) => !region.prompt);
if (emptyRegion) {
setEditingLocalEditRegionId(emptyRegion.id);
toast.error(`请填写区域 ${emptyRegion.index} 的提示词`);
return;
}
setIsSubmittingLocalEdit(true);
setIsProcessing(true);
commitLocalEditRegions(validRegions);
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: {
editMode: "local-edit",
sourceNodeId: node.id,
localEditRegions: validRegions,
},
},
connections: [node.id],
});
selectNode(newNodeId);
try {
const submitResult = await imageGenerationService.localEdit({
sourceImageUrl: data.src,
sourceNodeId: node.id,
regions: validRegions.map((region) => ({
id: region.id,
index: region.index,
color: region.color,
prompt: region.prompt,
rect: region.rect,
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;
updateNode(newNodeId, {
data: {
src: "",
status: "generating",
prompt: submitResult.data.prompt || `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
editMode: "local-edit",
sourceNodeId: node.id,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
localEditRegions: validRegions,
},
},
});
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.prompt || `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
model: task.result?.model || task.result?.modelRef || undefined,
metadata: {
editMode: "local-edit",
sourceNodeId: node.id,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
localEditRegions: validRegions,
},
},
});
resetLocalEditState();
toast.success("局部编辑结果已生成");
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: "local-edit",
sourceNodeId: node.id,
error: message,
localEditRegions: validRegions,
},
},
});
toast.error(message);
} finally {
setIsProcessing(false);
setIsSubmittingLocalEdit(false);
}
}, [
addNode,
commitLocalEditRegions,
data.src,
displayTitle,
isProcessing,
isSubmittingLocalEdit,
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;
}
setIsProcessing(true);
const actionName = "框选抠图";
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
connections: [node.id],
});
try {
const result = await imageGenerationService.boxCutout({
imageUrl: data.src,
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
sourceNodeId: node.id,
});
if (result.success && result.data?.url) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
cropRectApplied: result.data.cropRectApplied,
paddedCropRectApplied: result.data.paddedCropRectApplied,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
editMode: "box-cutout",
},
},
});
toast.success(`${actionName}完成`);
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(result.error || `${actionName}失败`);
}
} catch (error) {
console.error("框选抠图失败:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(`${actionName}失败,请重试`);
} finally {
setIsProcessing(false);
resetBoxCutoutState();
}
}, [
addNode,
boxCutoutSelection,
data.src,
displayTitle,
isProcessing,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetBoxCutoutState,
updateNode,
]);
const animateAddToChat = useCallback(async (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);
addPendingChatAttachment({
url: attachment.url,
name: displayTitle,
contentType: attachment.contentType,
});
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.src,
displayTitle,
isAddingToChat,
isProcessing,
]);
const getFloatingViewportBounds = (toolbarHeight: number) => {
const viewportPadding = 12;
let unsafeBottomTop = window.innerHeight;
const bottomOverlaySelectors = ['[data-canvas-floating-toolbar="true"]'];
for (const selector of bottomOverlaySelectors) {
const elements = Array.from(
document.querySelectorAll<HTMLElement>(selector)
);
for (const element of elements) {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") continue;
const overlayRect = element.getBoundingClientRect();
if (overlayRect.width <= 0 || overlayRect.height <= 0) continue;
if (overlayRect.bottom <= 0 || overlayRect.top >= window.innerHeight)
continue;
if (overlayRect.top < window.innerHeight * 0.55) continue;
unsafeBottomTop = Math.min(unsafeBottomTop, overlayRect.top);
}
}
const safeBottom = Math.max(
viewportPadding,
Math.min(
window.innerHeight - viewportPadding,
unsafeBottomTop - viewportPadding
)
);
const stageElement = document.querySelector(
'[data-canvas-stage="true"]'
) as HTMLElement | null;
const panelElement = document.querySelector(
'[data-canvas-right-panel="true"]'
) as HTMLElement | null;
let safeLeft = viewportPadding;
let safeRight = window.innerWidth - viewportPadding;
if (stageElement) {
const stageRect = stageElement.getBoundingClientRect();
if (stageRect.width > 0) {
safeLeft = Math.max(safeLeft, stageRect.left + viewportPadding);
safeRight = Math.min(safeRight, stageRect.right - viewportPadding);
}
}
if (panelElement) {
const panelRect = panelElement.getBoundingClientRect();
if (panelRect.width > 0 && panelRect.left < window.innerWidth) {
safeRight = Math.min(safeRight, panelRect.left - viewportPadding);
}
}
if (safeRight <= safeLeft + 8) {
safeLeft = viewportPadding;
safeRight = window.innerWidth - viewportPadding;
}
return {
viewportPadding,
safeBottom,
safeLeft,
safeRight,
maxTop: Math.max(viewportPadding, safeBottom - toolbarHeight),
};
};
// 获取工具栏位置 - 在渲染时直接计算
const getToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarWidth = Math.max(
280,
(toolbarSize.width || 980) * FLOATING_TOOLBAR_SCALE
);
const toolbarHeight = Math.max(
42,
(toolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
);
const titleSafeHeight = 34;
const gap = 12;
const { viewportPadding, maxTop, safeLeft, safeRight } =
getFloatingViewportBounds(toolbarHeight);
let top = rect.top - toolbarHeight - titleSafeHeight - gap;
if (top < viewportPadding) {
top = rect.bottom + gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
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 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);
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);
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();
updateNode(node.id, {
data: {
...data,
note: nextNote || undefined,
},
});
setDraftNote(nextNote);
setNotePanelOpen(false);
toast.success(nextNote ? "备注已保存" : "备注已清除");
}, [data, draftNote, node.id, updateNode]);
const detectTextItems = useCallback(
async (forceRefresh = false) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isDetectingText) {
return;
}
if (!forceRefresh && data.textEditSource === data.src) {
const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0) {
setTextEditItems(cached);
setDeletedTextItems([]);
setTextEditError(null);
return;
}
}
setIsDetectingText(true);
setTextEditError(null);
try {
const result = await imageGenerationService.detectCanvasText(data.src);
if (!result.success) {
throw new Error(result.error || "文字识别失败");
}
const detectedItems: ImageTextEditItem[] = (result.data?.items || [])
.map((item, index) => {
const text = (item.text || "").trim();
if (!text) return null;
return {
id: item.id || String(index + 1),
originalText: text,
editedText: text,
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
setTextEditItems(detectedItems);
setDeletedTextItems([]);
updateNode(node.id, {
data: {
...data,
textEditItems: detectedItems,
textEditSource: data.src,
textEditUpdatedAt: new Date().toISOString(),
},
});
if (detectedItems.length === 0) {
toast.info("未识别到可编辑文字");
} else {
toast.success(`已识别 ${detectedItems.length} 段文字`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "文字识别失败";
setTextEditError(message);
toast.error(message);
} finally {
setIsDetectingText(false);
}
},
[data, isDetectingText, node.id, updateNode]
);
const handleOpenTextEdit = useCallback(async () => {
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]);
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();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
]);
const handleTextItemChange = (id: string, value: string) => {
setTextEditItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, editedText: value } : item
)
);
};
const handleRemoveTextItem = (id: string) => {
setTextEditItems((prev) => {
const target = prev.find((item) => item.id === id);
if (target?.originalText) {
setDeletedTextItems((deletedPrev) => {
if (deletedPrev.some((item) => item.id === id)) return deletedPrev;
return [...deletedPrev, { id, originalText: target.originalText }];
});
}
return prev.filter((item) => item.id !== id);
});
};
const handleApplyTextEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
const changedItems: CanvasTextEditPayloadItem[] = textEditItems
.map((item) => {
const editedText = item.editedText.trim();
const action: "replace" | "delete" =
editedText.length === 0 ? "delete" : "replace";
return {
id: item.id,
originalText: item.originalText.trim(),
editedText,
action,
};
})
.filter(
(item) =>
item.originalText.length > 0 &&
(item.action === "delete" ||
(item.editedText &&
item.editedText.length > 0 &&
item.originalText !== item.editedText))
);
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
.map((item) => ({
id: item.id,
originalText: item.originalText.trim(),
action: "delete" as const,
}))
.filter((item) => item.originalText.length > 0);
const requestItems = [...changedItems, ...removedItems];
if (requestItems.length === 0) {
toast.info("请先修改或删除至少一段文字");
return;
}
if (isApplyingTextEdit) {
return;
}
setIsApplyingTextEdit(true);
setIsProcessing(true);
setTextEditError(null);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: "文字替换处理中...",
title: `${displayTitle}-文字替换`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const result = await imageGenerationService.editCanvasText({
imageUrl: data.src,
items: requestItems,
});
if (!result.success || !result.data?.originalUrl) {
throw new Error(result.error || "文字替换失败");
}
updateNode(newNodeId, {
data: {
src: result.data.originalUrl,
status: "completed",
prompt: "文字替换结果",
title: `${displayTitle}-文字替换`,
originalSrc: data.src,
model: result.data.model,
},
});
updateNode(node.id, {
data: {
...data,
textEditItems,
textEditSource: data.src,
textEditUpdatedAt: new Date().toISOString(),
},
});
setTextEditPanelOpen(false);
setDeletedTextItems([]);
toast.success("文字替换已完成");
} catch (error) {
const message = error instanceof Error ? error.message : "文字替换失败";
setTextEditError(message);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "文字替换失败",
title: `${displayTitle}-文字替换失败`,
originalSrc: data.src,
},
});
toast.error(message);
} finally {
setIsApplyingTextEdit(false);
setIsProcessing(false);
}
}, [
addNode,
data,
deletedTextItems,
isApplyingTextEdit,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
textEditItems,
updateNode,
]);
// 触发文件选择
const handleSelectFile = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
fileInputRef.current?.click();
};
// 处理图片上传 - 上传到存储桶,不使用 Base64
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 验证文件类型
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
// 验证文件大小 (50MB)
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
return;
}
setIsUploading(true);
// 先显示上传中状态
updateNode(node.id, {
data: { ...data, status: "generating" },
});
try {
const fileBaseName = file.name.replace(/\.[^.]+$/, "");
// 上传到存储桶
const result = await imageGenerationService.uploadImage(file);
if (result.success && result.data?.url) {
updateNode(node.id, {
data: {
...data,
src: result.data.url,
assetId: result.data.assetId,
status: "completed",
title: normalizeImageTitle(data.title) || fileBaseName,
},
});
toast.success("图片上传成功");
} else {
updateNode(node.id, {
data: { ...data, status: "failed" },
});
toast.error(result.error || "图片上传失败");
}
} catch (error) {
console.error("图片上传失败:", error);
updateNode(node.id, {
data: { ...data, status: "failed" },
});
toast.error("图片上传失败,请重试");
} finally {
setIsUploading(false);
}
// 重置 input
e.target.value = "";
};
// 获取操作的中文名称
const getActionName = (action: string): string => {
const names: Record<string, string> = {
rmbg: "抠图",
watermark: "去印",
enhance: "增强",
extend: "扩图",
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);
}
}
// 处理图片编辑操作 - 集成我们现有的 API
const handleAction = async (action: string) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
if (action !== "local-edit") {
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;
}
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 {
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 === "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);
setIsProcessing(true);
// 先创建一个"处理中"状态的节点,显示在原图右侧
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "", // 暂时没有图片
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
connections: [node.id],
});
let result;
try {
switch (action) {
case "rmbg":
result = await imageGenerationService.removeBackground(data.src);
break;
case "watermark":
result = await imageGenerationService.removeWatermark(data.src);
break;
case "enhance":
result = await imageGenerationService.enhanceImage(data.src);
break;
case "extend":
result = await imageGenerationService.extendImage(data.src);
break;
default:
// 删除创建的节点
if (newNodeId) deleteNode(newNodeId);
return;
}
if (result?.success && result.data?.url) {
// 更新节点为完成状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
});
}
toast.success(`${actionName}处理完成`);
} else {
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error("处理失败,请重试");
} finally {
setIsProcessing(false);
}
};
useEffect(() => {
if (typeof window === "undefined") return;
const onExternalAction = (event: Event) => {
const customEvent = event as CustomEvent<{
nodeId?: string;
action?: string;
}>;
const targetNodeId = customEvent.detail?.nodeId;
const action = customEvent.detail?.action;
if (!targetNodeId || !action || targetNodeId !== node.id) return;
void handleAction(action);
};
window.addEventListener(
"canvas:image-action",
onExternalAction as EventListener
);
return () => {
window.removeEventListener(
"canvas:image-action",
onExternalAction as EventListener
);
};
}, [handleAction, node.id]);
const 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) => {
setAnnotatorOpen(false);
// 如果仍然是 base64 格式,先上传到服务器(ImageAnnotator 应该已经处理了,这里作为保护)
let finalUrl = annotatedImageUrl;
if (annotatedImageUrl.startsWith("data:")) {
try {
toast.info("正在上传图片...");
const response = await fetch(annotatedImageUrl);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `annotated-${Date.now()}.png`);
const uploadResponse = await fetchUploadWithRetry(formData);
if (uploadResponse.ok) {
const result = await uploadResponse.json();
finalUrl = result.url;
} else {
toast.error("上传失败");
return;
}
} catch (error) {
console.error("上传标注图片失败:", error);
toast.error("上传失败");
return;
}
}
// 更新当前节点,标记为已标注图片
// 标注后的图片会带有视觉标记(箭头、圆圈、文字等)
// 这些标记指示了用户希望 AI 重点关注/修改的区域
updateNode(node.id, {
data: {
...data,
src: finalUrl,
hasAnnotations: true, // 🆕 标记为已标注
originalSrc: data.originalSrc || data.src, // 保留原始图片
prompt: data.prompt ? `${data.prompt} (已标注)` : "已标注图片",
},
});
toast.success("标注已保存!发送对话时将重点处理标注区域");
};
const uploadBase64ToStorage = useCallback(async (base64Url: string) => {
const response = await fetch(base64Url);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `canvas-resize-${Date.now()}.png`);
const uploadResponse = await fetchUploadWithRetry(formData);
if (!uploadResponse.ok) {
throw new Error("上传失败");
}
const result = await uploadResponse.json();
return result.url as string;
}, []);
const handleResizeEditorComplete = useCallback(
async (resizedImageUrl: string) => {
let finalUrl = resizedImageUrl;
if (resizedImageUrl.startsWith("data:")) {
try {
toast.info("正在上传尺寸调整结果...");
finalUrl = await uploadBase64ToStorage(resizedImageUrl);
} catch (error) {
console.error("上传尺寸调整图片失败:", error);
toast.error("尺寸调整结果上传失败");
return;
}
}
const title = `${displayTitle}-尺寸调整`;
addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: finalUrl,
status: "completed",
prompt: "尺寸调整结果",
title,
originalSrc: data.src,
},
connections: [node.id],
});
setResizeAnnotatorOpen(false);
toast.success("尺寸调整结果已发送到画布");
},
[
addNode,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
uploadBase64ToStorage,
]
);
const 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" : imageUrl ? "image" : "mesh",
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();
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 {
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,
},
},
connections: [node.id],
});
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsProcessing(true);
try {
const uploadResult = await worldLabsGenerationService.uploadMediaFromUrl(
sourceImageUrl,
"image"
);
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
throw new Error(uploadResult.error || "上传世界参考图失败");
}
const providerId = uploadResult.data.providerId || null;
const providerName = uploadResult.data.providerName || null;
const createResult = await worldLabsGenerationService.createTask({
mode: "image",
mediaAssetId: uploadResult.data.mediaAssetId,
providerId,
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 || {}),
providerId,
providerName,
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,
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,
providerName,
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 {
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);
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 {
setIsProcessing(false);
}
};
// 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转)
const handleDownload = async () => {
if (!data.src) return;
const originalUrl = String(inferOriginalImageUrl(data.src) || data.src || "").trim();
if (!originalUrl) {
toast.error("下载失败,请重试");
return;
}
const urlParts = originalUrl.split(".");
const ext = urlParts[urlParts.length - 1]?.split("?")[0] || "png";
const filename = `${sanitizeFileName(displayTitle)}.${ext}`;
try {
toast.info("正在准备下载...");
const response = await fetch(
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
);
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
// 创建 Blob URL
const blobUrl = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
// 触发下载
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放 Blob URL
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
toast.success("下载成功");
} catch (error) {
console.error("下载失败:", error);
try {
triggerBrowserDownload(originalUrl, filename);
toast.message("代理下载失败,已切换为原图直链下载");
return;
} catch (fallbackError) {
console.error("原图直链下载失败:", fallbackError);
}
try {
triggerBrowserDownload(originalUrl, undefined, { openInNewTab: true });
toast.message("已打开原图,可直接另存为");
} catch (openError) {
console.error("打开原图失败:", openError);
toast.error("下载失败,请重试");
}
}
};
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(() => {
if (!hasActiveImageAdjustments) return null;
const temperatureOpacity = Math.min(
0.38,
Math.abs(activeImageAdjustments.temperature) / 260
);
const tintOpacity = Math.min(
0.28,
Math.abs(activeImageAdjustments.tint) / 300
);
const grainOpacity = Math.min(0.28, activeImageAdjustments.grain / 340);
const vignetteOpacity = Math.min(
0.42,
activeImageAdjustments.vignette / 180
);
const softLightOpacity = Math.min(
0.24,
activeImageAdjustments.softLight / 320
);
const glowOpacity = Math.min(0.22, activeImageAdjustments.glow / 280);
return (
<>
{temperatureOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
background:
activeImageAdjustments.temperature >= 0
? "linear-gradient(135deg, rgba(255,188,109,0.9), rgba(255,232,194,0.42))"
: "linear-gradient(135deg, rgba(109,180,255,0.86), rgba(219,240,255,0.36))",
mixBlendMode: "soft-light",
opacity: temperatureOpacity,
}}
/>
) : null}
{tintOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
background:
activeImageAdjustments.tint >= 0
? "linear-gradient(135deg, rgba(244,105,214,0.78), rgba(255,203,236,0.28))"
: "linear-gradient(135deg, rgba(76,214,141,0.76), rgba(194,255,221,0.28))",
mixBlendMode: "overlay",
opacity: tintOpacity,
}}
/>
) : null}
{softLightOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
background:
"linear-gradient(180deg, rgba(255,247,229,0.84) 0%, rgba(255,255,255,0.08) 34%, rgba(255,219,168,0.42) 100%)",
mixBlendMode: "soft-light",
opacity: softLightOpacity,
}}
/>
) : null}
{glowOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
boxShadow:
"inset 0 0 72px rgba(255,255,255,0.78), inset 0 0 24px rgba(255,236,201,0.66)",
mixBlendMode: "screen",
opacity: glowOpacity,
}}
/>
) : null}
{vignetteOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
background:
"radial-gradient(circle at center, rgba(0,0,0,0) 38%, rgba(0,0,0,0.06) 58%, rgba(0,0,0,0.34) 100%)",
mixBlendMode: "multiply",
opacity: vignetteOpacity,
}}
/>
) : null}
{grainOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
backgroundImage: `url("${IMAGE_ADJUST_NOISE_DATA_URI}")`,
backgroundRepeat: "repeat",
backgroundSize: "140px 140px",
mixBlendMode: "overlay",
opacity: grainOpacity,
}}
/>
) : null}
</>
);
}, [
activeImageAdjustments,
hasActiveImageAdjustments,
imageAdjustOverlayStyle,
]);
const cropPanelPosition = getCropPanelPosition();
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
const toolbarPosition = getToolbarPosition();
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
const flipRotatePanelPosition = getFlipRotatePanelPosition();
const anglePanelPosition = getAnglePanelPosition();
const imageAdjustTabOptions = [
{
id: "light" as const,
label: "光线",
icon: SlidersHorizontal,
},
{
id: "color" as const,
label: "颜色",
icon: Blend,
},
{
id: "detail" as const,
label: "细节",
icon: CircleDot,
},
{
id: "effects" as const,
label: "效果",
icon: Box,
},
];
const imageAdjustSections: Record<
ImageAdjustPanelTab,
Array<{
key: ImageAdjustmentKey;
label: string;
min: number;
max: number;
trackClassName?: string;
}>
> = {
light: [
{ key: "light", label: "光线", min: -100, max: 100 },
{ key: "exposure", label: "曝光", min: -100, max: 100 },
{ key: "contrast", label: "对比度", min: -100, max: 100 },
{ key: "highlights", label: "高光", min: -100, max: 100 },
{ key: "shadows", label: "阴影", min: -100, max: 100 },
{ key: "whites", label: "白色", min: -100, max: 100 },
{ key: "blacks", label: "黑色", min: -100, max: 100 },
],
color: [
{ key: "vibrance", label: "自然饱和度", min: -100, max: 100 },
{ key: "saturation", label: "饱和度", min: -100, max: 100 },
{
key: "temperature",
label: "色温",
min: -100,
max: 100,
trackClassName: "h-[4px] bg-[linear-gradient(90deg,#4aa7ff,#f2c23a)]",
},
{
key: "tint",
label: "色调",
min: -100,
max: 100,
trackClassName: "h-[4px] bg-[linear-gradient(90deg,#ff66d7,#41d278)]",
},
],
detail: [
{ key: "sharpen", label: "锐化", min: 0, max: 100 },
{ key: "clarity", label: "清晰度", min: 0, max: 100 },
{ key: "grain", label: "颗粒", min: 0, max: 100 },
],
effects: [
{ key: "vignette", label: "晕影", min: 0, max: 100 },
{ key: "softLight", label: "柔光", min: 0, max: 100 },
{ key: "glow", label: "辉光", min: 0, max: 100 },
],
};
const boxCutoutHintText = boxCutoutSelection
? "确认后将仅抠出框选区域"
: isBoxCutoutDrawing
? "松开完成框选"
: "在图片中拖拽矩形框";
return (
<>
{/* 选中时在图片上方显示标题 */}
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
<div
className="-top-7 absolute right-0 left-0 z-10 flex items-center justify-between px-1 text-[#2f7df6] text-xs"
style={selectedTitleStyle}
>
<div className="flex min-w-0 items-center gap-1">
<ImagePlus className="h-3 w-3" />
{isEditingTitle ? (
<Input
autoFocus
className="h-6 w-[160px] px-2 py-0 text-xs"
data-canvas-no-drag="true"
onBlur={handleSaveTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveTitle();
} else if (e.key === "Escape") {
e.preventDefault();
setDraftTitle(displayTitle);
setIsEditingTitle(false);
}
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
value={draftTitle}
/>
) : (
<span
className="max-w-[150px] cursor-text truncate"
onDoubleClick={(e) => {
e.stopPropagation();
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
title="双击重命名"
>
{displayTitle}
</span>
)}
</div>
{resolutionLabel ? (
<span className="ml-2 shrink-0 tabular-nums tracking-wide">
{resolutionLabel}
</span>
) : null}
</div>
)}
<div
className={cn(
"relative h-full w-full overflow-hidden 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",
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}
>
<div className="relative h-full w-full" style={scaledContentStyle}>
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? (
<div className="relative h-full w-full">
{!shouldRequestDisplayImage ? (
<div className="flex h-full w-full items-center justify-center bg-slate-50 text-slate-400 text-xs dark:bg-zinc-900 dark:text-zinc-500">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>图片即将进入视口时加载</span>
</div>
</div>
) : 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);
}}
ref={compareFrameRef}
style={{ touchAction: "none" }}
>
<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-contain"
decoding="async"
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
style={activeImageAdjustmentsStyle}
src={displayImageSrc}
/>
{renderImageAdjustmentOverlays()}
</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-contain"
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: `${compareSplitPercent}%` }}
>
&lt;&gt;
</div>
<div className="pointer-events-none absolute top-2 left-2 rounded-full border border-[#dbe2ee] bg-white px-2 py-0.5 text-[10px] text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
新图
</div>
<div className="pointer-events-none absolute top-2 right-2 rounded-full border border-[#dbe2ee] bg-white px-2 py-0.5 text-[10px] text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
原图
</div>
</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={refreshBoxCutoutMetrics}
ref={displayImageRef}
src={displayImageSrc}
style={activeImageAdjustmentsStyle}
/>
{renderImageAdjustmentOverlays()}
{shouldRenderDetailOverlay ? (
detailUiDslDocument ? (
<DetailUiDomRenderer
document={detailUiDslDocument}
imageMetrics={boxCutoutMetrics}
/>
) : (
<DetailSurfaceOverlayRenderer
imageMetrics={boxCutoutMetrics}
overlay={detailUiScreenOverlay}
subjectAnalysis={detailSubjectAnalysis}
/>
)
) : 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}
{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);
return (
<div
className={cn(
"nodrag nopan nowheel pointer-events-auto absolute z-[160] rounded-[10px] border-[3px] bg-transparent shadow-[0_0_0_1px_rgba(255,255,255,0.92),0_0_22px_rgba(0,0,0,0.2)]",
localEditRegionInteraction?.regionId === region.id
? "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,
}}
>
<div
className="-left-3 -top-3 absolute flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[12px] font-black text-white shadow-[0_8px_18px_rgba(0,0,0,0.22)]"
style={{ backgroundColor: region.color }}
>
{region.index}
</div>
{(
[
{
handle: "nw",
className: "-left-2 -top-2 cursor-nwse-resize",
},
{
handle: "ne",
className: "-right-2 -top-2 cursor-nesw-resize",
},
{
handle: "sw",
className: "-left-2 -bottom-2 cursor-nesw-resize",
},
{
handle: "se",
className: "-right-2 -bottom-2 cursor-nwse-resize",
},
] as const
).map((item) => (
<button
aria-label={`调整区域 ${region.index}`}
className={cn(
"nodrag nopan nowheel absolute h-4 w-4 rounded-full border-2 border-white shadow-[0_4px_12px_rgba(0,0,0,0.22)]",
item.className
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
data-local-edit-region-handle={item.handle}
key={item.handle}
style={{ backgroundColor: region.color }}
type="button"
/>
))}
</div>
);
})}
{localEditDraftRectStyle ? (
<div
className="pointer-events-none absolute z-[162] rounded-[10px] border-[3px] border-emerald-400 bg-emerald-300/10 shadow-[0_0_0_1px_rgba(255,255,255,0.9),0_0_22px_rgba(16,185,129,0.35)]"
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>
) : data.status === "generating" ? (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="select-none px-3 text-sm font-medium text-[#3f4b63]">
{detailProgressLabel || "生成中..."}
</span>
<span className="mt-1 text-xs text-[#7a8395]">图片处理中</span>
</div>
) : data.status === "failed" ? (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
<AlertCircle className="h-5 w-5" />
</span>
<span className="select-none text-sm font-medium text-[#c84b5e]">
生成失败
</span>
<span className="mt-1 text-xs text-[#b77480]">请重新上传或再试一次</span>
</div>
) : (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<ImagePlus className="h-5 w-5" />
</span>
<span className="select-none text-sm font-medium text-[#3f4b63]">
上传图片
</span>
<span className="mb-4 mt-1 select-none text-xs text-[#7a8395]">
添加参考图、角色图或关键帧素材
</span>
{/* 隐藏的文件输入 */}
<input
accept="image/*"
className="hidden"
onChange={handleImageUpload}
ref={fileInputRef}
type="file"
/>
{/* 选择文件按钮 */}
<button
className="cursor-pointer rounded-[14px] border border-[#d7dff1] bg-white px-4 py-2 text-sm font-medium text-[#3954db] shadow-[0_0_0_1px_rgba(219,226,238,0.24)] transition-colors hover:bg-[#f4f7ff]"
onClick={handleSelectFile}
type="button"
>
选择文件
</button>
</div>
)}
{/* 处理中遮罩 */}
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-white">
<div className="flex flex-col items-center">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-[#f6f8ff] text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63]">处理中...</span>
</div>
</div>
)}
{/* 工具栏使用 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}
/>
{imageRemoveBackgroundItem.label}
</DropdownMenuItem>
) : null}
{imageRemoveWatermarkEnabled ? (
<DropdownMenuItem onClick={() => handleAction("watermark")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
{imageRemoveWatermarkItem.label}
</DropdownMenuItem>
) : null}
{imageEnhanceEnabled ? (
<DropdownMenuItem onClick={() => handleAction("enhance")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Sparkles}
item={imageEnhanceItem}
/>
{imageEnhanceItem.label}
</DropdownMenuItem>
) : null}
{imageNoteEnabled ? (
<DropdownMenuItem onClick={() => handleAction("note")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</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}
/>
{imageMultiAngleItem.label}
</DropdownMenuItem>
) : null}
{imagePromptRegenerateEnabled ? (
<DropdownMenuItem
onClick={() => handleAction("prompt-regenerate")}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
{imagePromptRegenerateItem.label}
</DropdownMenuItem>
) : null}
{imageTextEditEnabled ? (
<DropdownMenuItem onClick={() => handleAction("text-edit")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Type}
item={imageTextEditItem}
/>
{imageTextEditItem.label}
</DropdownMenuItem>
) : null}
{imageExtendEnabled ? (
<DropdownMenuItem onClick={() => handleAction("extend")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
/>
{imageExtendItem.label}
</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 onClick={handleDownload}>
<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}
{/* 提示词重绘对话框 */}
<Dialog onOpenChange={setPromptDialogOpen} open={promptDialogOpen}>
<DialogContent
className="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">
<RefreshCw className="h-5 w-5" />
提示词重绘
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-muted-foreground text-sm">
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
</p>
<div className="space-y-2">
<Label htmlFor={`prompt-regenerate-${node.id}`}>新提示词</Label>
<Textarea
className="resize-y border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
id={`prompt-regenerate-${node.id}`}
onChange={(e) => setRegeneratePrompt(e.target.value)}
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
rows={6}
value={regeneratePrompt}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() => setPromptDialogOpen(false)}
variant="outline"
>
取消
</Button>
<Button
disabled={isProcessing || !regeneratePrompt.trim()}
onClick={handlePromptRegenerate}
>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
开始重绘
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
onOpenChange={(open) => {
if (!open) {
setThreedGenerationDialogMode(null);
}
}}
open={threedGenerationDialogMode !== null}
>
<DialogContent
className="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">
{threedGenerationDialogMode === "world" ? (
<Globe2 className="h-5 w-5" />
) : (
<Box className="h-5 w-5" />
)}
{threedGenerationDialogMode === "world"
? "选择 3D 世界模型"
: "选择 3D 模型"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-muted-foreground text-sm">
{threedGenerationDialogMode === "world"
? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。"
: "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"}
</p>
<div className="space-y-2">
<Label>
{threedGenerationDialogMode === "world" ? "世界模型" : "模型版本"}
</Label>
<Select
onValueChange={(value) => {
if (threedGenerationDialogMode === "world") {
setSelectedWorldModelVersion(value);
return;
}
setSelectedTripoModelVersion(value);
}}
value={
threedGenerationDialogMode === "world"
? selectedWorldModelVersion
: selectedTripoModelVersion
}
>
<SelectTrigger className="border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-950">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950">
{(threedGenerationDialogMode === "world"
? worldModelOptions
: tripoModelOptions
).map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-800 dark:bg-slate-900/80">
<div className="font-medium text-slate-900 dark:text-slate-100">
{threedGenerationDialogMode === "world"
? selectedWorldModelEntry?.displayName || "未选择模型"
: selectedTripoModelEntry?.displayName || "未选择模型"}
</div>
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
{threedGenerationDialogMode === "world"
? selectedWorldModelEntry?.description || "当前模型暂无说明。"
: selectedTripoModelEntry?.description || "当前模型暂无说明。"}
</div>
</div>
</div>
<DialogFooter>
<Button
onClick={() => setThreedGenerationDialogMode(null)}
variant="outline"
>
取消
</Button>
<Button
disabled={
isProcessing ||
(threedGenerationDialogMode === "world"
? !selectedWorldModelEntry
: !selectedTripoModelEntry)
}
onClick={() => void handleConfirmThreedGeneration()}
>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : threedGenerationDialogMode === "world" ? (
<Globe2 className="mr-2 h-4 w-4" />
) : (
<Box className="mr-2 h-4 w-4" />
)}
开始生成
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{localEditMode &&
typeof document !== "undefined" &&
localEditPromptPortalOverlays.map(({ region, isEditing, style }) =>
createPortal(
<div
className="nodrag nopan nowheel fixed z-[10000] flex flex-col items-stretch gap-1.5"
data-canvas-no-drag="true"
data-local-edit-control="true"
key={region.id}
onClick={(event) => event.stopPropagation()}
onClickCapture={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onMouseDownCapture={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onPointerDownCapture={(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 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"
defaultValue={
localEditPromptDraftRefs.current[region.id] ?? region.prompt
}
onBlur={(event) => {
const nextPrompt = event.currentTarget.value;
localEditPromptDraftRefs.current[region.id] = nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
if (isLocalEditControlTarget(event.relatedTarget)) {
return;
}
window.setTimeout(() => {
if (isLocalEditControlTarget(document.activeElement)) {
return;
}
setEditingLocalEditRegionId((current) => {
if (current === region.id) {
focusLocalEditPromptInput(region.id);
}
return current;
});
}, 80);
}}
onClick={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onFocus={(event) => {
event.stopPropagation();
setEditingLocalEditRegionId((current) =>
current === region.id ? current : region.id
);
}}
onInput={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onChange={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onKeyDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onKeyUp={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onPointerDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
/>
) : (
<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"
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="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"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
document
.getElementById(`local-edit-reference-upload-${region.id}`)
?.click();
}}
disabled={
uploadingLocalEditReferenceRegionId === region.id ||
(region.referenceImages?.length || 0) >=
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION
}
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);
}}
title="移除参考图"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
</div>,
document.body
)
)}
{isSelected &&
!isMultiSelected &&
data.src &&
localEditMode &&
!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-slate-700 dark:bg-slate-900 dark:text-gray-100"
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">局部编辑</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="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<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
}
onClick={() => void handleSubmitLocalEdit()}
title="生成局部编辑结果"
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
)}
{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 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-slate-700 dark:bg-slate-900 dark:text-gray-100"
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: "auto",
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 ? (
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
) : null}
{imagePickObjectEnabled ? (
<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}
{imageEnhanceEnabled ? (
<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("enhance")}
title={imageEnhanceItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
item={imageEnhanceItem}
/>
{imageEnhanceItem.label}
</Button>
) : null}
{imageRemoveBackgroundEnabled ? (
<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("rmbg")}
title={imageRemoveBackgroundItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Scissors}
item={imageRemoveBackgroundItem}
/>
{imageRemoveBackgroundItem.label}
</Button>
) : null}
{imageBoxCutoutEnabled ? (
<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("box-cutout")}
title={imageBoxCutoutItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Scan}
item={imageBoxCutoutItem}
/>
{imageBoxCutoutItem.label}
</Button>
) : null}
{imageLocalEditEnabled ? (
<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 || 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}
</Button>
) : null}
{imageRemoveWatermarkEnabled ? (
<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("watermark")}
title={imageRemoveWatermarkItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
{imageRemoveWatermarkItem.label}
</Button>
) : null}
{imageResizeEnabled ? (
<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}
{imageTextEditEnabled ? (
<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}
{imageMultiAngleEnabled ? (
<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("angle")}
title={imageMultiAngleItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
/>
{imageMultiAngleItem.label}
</Button>
) : null}
{imageGenerate3dModelEnabled ? (
<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("generate-3d-model")}
title={imageGenerate3dModelItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Box}
item={imageGenerate3dModelItem}
/>
{imageGenerate3dModelItem.label}
</Button>
) : null}
{imageGenerate3dWorldEnabled ? (
<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("generate-3d-world")}
title={imageGenerate3dWorldItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Globe2}
item={imageGenerate3dWorldItem}
/>
{imageGenerate3dWorldItem.label}
</Button>
) : null}
{imageLayerSplitEnabled ? (
<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}
{hasSelectedImagePrimaryToolbarActions &&
hasSelectedImageSecondaryToolbarActions ? (
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
) : 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-slate-900 dark:text-slate-100"
side="bottom"
>
{imageExtendEnabled ? (
<DropdownMenuItem onClick={() => handleAction("extend")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
/>
{imageExtendItem.label}
</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}
{imagePromptRegenerateEnabled ? (
<DropdownMenuItem
onClick={() => handleAction("prompt-regenerate")}
>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
{imagePromptRegenerateItem.label}
</DropdownMenuItem>
) : null}
{imageNoteEnabled ? (
<DropdownMenuItem onClick={() => handleAction("note")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</DropdownMenuItem>
) : null}
{hasSelectedImageMorePrimaryActions && 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}
onClick={handleDownload}
size="icon"
title={imageDownloadItem.label}
variant="ghost"
>
<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-slate-700 dark:bg-slate-900 dark:text-gray-100"
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-slate-700" />
<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">
备注仅在你点开时显示,不会直接覆盖在画面上。
</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
)}
{isSelected &&
imageAdjustPanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed z-[9999] w-[520px] 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: imageAdjustPanelPosition.top,
left: imageAdjustPanelPosition.left,
transform: `scale(${IMAGE_EDIT_PANEL_SCALE})`,
transformOrigin: "top left",
}}
>
<div className="flex items-center justify-between px-8 pt-8 pb-5">
<h3 className="text-[20px] font-semibold tracking-[0] text-[#18181b]">
{imageAdjustItem.label}
</h3>
<div className="flex items-center gap-2">
<button
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-[#8c8d92] transition-colors hover:bg-[#f4f2ed] hover:text-[#2f3136] disabled:cursor-not-allowed disabled:text-[#cbc7c0]"
disabled={isDefaultImageAdjustments(imageAdjustmentsDraft)}
onClick={handleResetImageAdjustments}
type="button"
>
<Sparkles className="h-5 w-5" />
</button>
<button
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-[#8c8d92] transition-colors hover:bg-[#f4f2ed] hover:text-[#2f3136]"
onClick={() => setImageAdjustPanelOpen(false)}
type="button"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-7 pb-7">
<div className="rounded-[20px] bg-[#f3f1ec] p-1">
<div className="grid grid-cols-4 gap-1">
{imageAdjustTabOptions.map((item) => {
const active = imageAdjustTab === item.id;
const Icon = item.icon;
return (
<button
className={cn(
"inline-flex h-14 items-center justify-center rounded-[16px] text-[#7a7a7d] transition-all",
active
? "bg-white text-[#23262d] shadow-[0_1px_2px_rgba(0,0,0,0.05)]"
: "hover:text-[#2d3138]"
)}
key={item.id}
onClick={() => setImageAdjustTab(item.id)}
title={item.label}
type="button"
>
<Icon className="h-5 w-5" />
</button>
);
})}
</div>
</div>
<div className="mt-7 max-h-[600px] overflow-y-auto pr-2">
<div className="space-y-7 pb-2">
{imageAdjustSections[imageAdjustTab].map((field) => (
<ImageAdjustPanelSlider
disabled={false}
key={field.key}
label={field.label}
max={field.max}
min={field.min}
onChange={(value) =>
setImageAdjustmentsDraft((prev) => ({
...prev,
[field.key]: value,
}))
}
trackClassName={field.trackClassName}
value={imageAdjustmentsDraft[field.key]}
/>
))}
</div>
</div>
</div>
</div>,
document.body
)}
{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="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" />
生成中
</>
) : (
<>
立即使用
<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}
/>
</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 ||
(textEditItems.length === 0 && deletedTextItems.length === 0)
}
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" />
)}
确认替换
</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>
{/* 图片 */}
<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
)}
</>
);
}