10833 lines
378 KiB
TypeScript
10833 lines
378 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Box,
|
||
Camera,
|
||
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,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import type React from "react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { toast } from "sonner";
|
||
import useSWR from "swr";
|
||
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
||
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
|
||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||
import {
|
||
areImageAdjustmentsEqual,
|
||
buildImageAdjustmentFilterStyle,
|
||
DEFAULT_IMAGE_ADJUSTMENTS,
|
||
ImageAdjustFloatingPanel,
|
||
type ImageAdjustmentState,
|
||
type ImageAdjustPanelTab,
|
||
isDefaultImageAdjustments,
|
||
normalizeImageAdjustments,
|
||
} from "@/components/canvas/nodes/image-node/image-node-adjustments";
|
||
import { ImageNodeAdjustOverlays } from "@/components/canvas/nodes/image-node/image-node-adjust-overlays";
|
||
import {
|
||
ImageNodeDeferredLoadState,
|
||
ImageNodeEmptyState,
|
||
ImageNodeProcessingOverlay,
|
||
} from "@/components/canvas/nodes/image-node/image-node-empty-state";
|
||
import {
|
||
BoxCutoutPoint,
|
||
createCenteredCropRectForAspect,
|
||
createCenteredCropRectFromSize,
|
||
createNormalizedRect,
|
||
CROP_PRESET_GROUPS,
|
||
type CropInteractionHandle,
|
||
type CropPanelGroupKey,
|
||
DEFAULT_CROP_PRESET_ID,
|
||
getCropPresetById,
|
||
getNormalizedMinCropSize,
|
||
updateCropRectWithPointer,
|
||
} from "@/components/canvas/nodes/image-node/image-node-crop";
|
||
import {
|
||
getImageNodeTitle,
|
||
guessNearestAspectRatio,
|
||
inferModelFormat,
|
||
inferTripoImageType,
|
||
normalizeImageIdentityUrl,
|
||
normalizeImageTitle,
|
||
normalizeMediaUrl,
|
||
resolveRegeneratePromptSeed,
|
||
resolveOriginalImageUrl,
|
||
sanitizeFileName,
|
||
triggerBrowserDownload,
|
||
} from "@/components/canvas/nodes/image-node/image-node-utils";
|
||
import {
|
||
createLocalEditPromptDraftMap,
|
||
createLocalEditReferenceImageId,
|
||
createLocalEditRegionId,
|
||
getLocalEditRegionsFromMetadata,
|
||
getNormalizedRectOverlayStyle,
|
||
isHttpImageUrl,
|
||
LOCAL_EDIT_COLORS,
|
||
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
|
||
LOCAL_EDIT_MAX_REGIONS,
|
||
LOCAL_EDIT_MAX_POLL_ATTEMPTS,
|
||
LOCAL_EDIT_METADATA_KEY,
|
||
LOCAL_EDIT_MIN_EDGE_PX,
|
||
LOCAL_EDIT_POLL_INTERVAL_MS,
|
||
reindexLocalEditRegions,
|
||
type LocalEditReferenceImage,
|
||
type LocalEditRegion,
|
||
} from "@/components/canvas/nodes/image-node/image-node-local-edit";
|
||
import {
|
||
PromptRegenerateDialog,
|
||
ThreeDGenerationDialog,
|
||
} from "@/components/canvas/nodes/image-node/image-node-dialogs";
|
||
import { ImageNodeSelectedTitle } from "@/components/canvas/nodes/image-node/image-node-title";
|
||
import { ImageAnnotator } 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 {
|
||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||
getDefaultThreeDModelAliasId,
|
||
resolveThreeDModelBillingId,
|
||
type PublicThreeDModelAliasConfig,
|
||
} from "@/lib/model3d/model-alias-config";
|
||
import {
|
||
calculateEnhanceProPointsEstimate,
|
||
defaultPointsSystemConfig,
|
||
get3DTaskCreditCost,
|
||
get3DWorldCreditCost,
|
||
getImageToolCreditCost,
|
||
normalizePointsSystemConfig,
|
||
type ImageToolCreditFeature,
|
||
type PointsSystemConfig,
|
||
} from "@/lib/points/config";
|
||
import {
|
||
isArchivedWorldLabsAssetUrl,
|
||
pickBestArchivedWorldLabsSplatUrl,
|
||
} from "@/lib/worldlabs/splat-utils";
|
||
import {
|
||
getNodeContentScale,
|
||
getScaledNodeContentStyle,
|
||
} from "@/lib/canvas/node-content-scale";
|
||
import {
|
||
getSelectedImageInlineActionCount,
|
||
getCanvasToolbarItem,
|
||
isCanvasToolbarItemEnabled,
|
||
type CanvasToolbarConfig,
|
||
} from "@/lib/canvas-toolbar-config";
|
||
import {
|
||
AI_WASH_CONTROL_ORDER,
|
||
AI_WASH_DEFAULT_MODE_LABEL,
|
||
defaultAiWashConfig,
|
||
getEnabledAiWashPresets,
|
||
normalizeAiWashConfig,
|
||
type AiWashConfig,
|
||
type AiWashControl,
|
||
type AiWashControlId,
|
||
type AiWashControlLevel,
|
||
type AiWashPreset,
|
||
} from "@/lib/ai-wash/config";
|
||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||
import { cn, fetcher } from "@/lib/utils";
|
||
import type {
|
||
CanvasTextEditPayloadItem,
|
||
NormalizedCropRect,
|
||
} from "@/services/canvas";
|
||
import {
|
||
canvasService,
|
||
imageGenerationService,
|
||
model3DGenerationService,
|
||
worldLabsGenerationService,
|
||
} from "@/services/canvas";
|
||
import type { PickedObject } from "@/store/canvas";
|
||
import { useCanvasStore } from "@/store/canvas";
|
||
import type {
|
||
CanvasNode,
|
||
ImageNodeData,
|
||
ImageTextEditItem,
|
||
} from "@/types/canvas";
|
||
|
||
// 已标注的物品标记(本地状态)
|
||
interface PickedMarker {
|
||
clickX: number;
|
||
clickY: number;
|
||
objectName: string;
|
||
index: number;
|
||
}
|
||
|
||
interface ImageDisplayMetrics {
|
||
offsetX: number;
|
||
offsetY: number;
|
||
renderedWidth: number;
|
||
renderedHeight: number;
|
||
naturalWidth: number;
|
||
naturalHeight: number;
|
||
}
|
||
|
||
interface ImageOverlayRectStyle {
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
interface LayerSplitBounds {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
originalWidth: number;
|
||
originalHeight: number;
|
||
coverage: number;
|
||
}
|
||
|
||
const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
||
"ai-wash",
|
||
"rmbg",
|
||
"watermark",
|
||
"enhance",
|
||
"extend",
|
||
"layer-split",
|
||
"box-cutout",
|
||
"local-edit",
|
||
"text-edit",
|
||
"prompt-regenerate",
|
||
"generate-3d-model",
|
||
"generate-3d-world",
|
||
]);
|
||
|
||
type AiWashResultStatus =
|
||
| "idle"
|
||
| "submitting"
|
||
| "running"
|
||
| "succeeded"
|
||
| "failed";
|
||
|
||
type AiWashModelOption = {
|
||
id: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
avatarUrl?: string | null;
|
||
providerName?: string;
|
||
providerType?: string;
|
||
isDefault?: boolean;
|
||
unitCost: number;
|
||
creditDiscountLabel?: string | null;
|
||
availabilityStatus?: "available" | "maintenance" | "testing";
|
||
availabilityLabel?: string | null;
|
||
selectable?: boolean;
|
||
disabledReason?: string | null;
|
||
};
|
||
|
||
type AiWashConfigResponse = {
|
||
success?: boolean;
|
||
config?: AiWashConfig;
|
||
lockedModels?: AiWashModelOption[];
|
||
};
|
||
|
||
type AiWashModelOptionsResponse = {
|
||
success?: boolean;
|
||
defaultModelId?: string | null;
|
||
models?: AiWashModelOption[];
|
||
};
|
||
|
||
type MembershipSummaryResponse = {
|
||
isHighVip?: boolean;
|
||
pointsSystemConfig?: PointsSystemConfig;
|
||
};
|
||
|
||
type ImageActionCreditBadgeProps = {
|
||
label?: string | null;
|
||
compact?: boolean;
|
||
className?: string;
|
||
};
|
||
|
||
function formatPointAmount(value: number) {
|
||
const normalized = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
||
return normalized.toLocaleString("en-US");
|
||
}
|
||
|
||
function formatImageNodeFeatureCostLabel({
|
||
cost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
}: {
|
||
cost: number;
|
||
isHighVip?: boolean;
|
||
pointsEnabled: boolean;
|
||
}) {
|
||
if (!pointsEnabled) return "不扣积分";
|
||
if (isHighVip) return "VIP免费";
|
||
if (cost <= 0) return "免费";
|
||
return `${formatPointAmount(cost)} 积分`;
|
||
}
|
||
|
||
function formatImageNodeFeatureCostShortLabel({
|
||
cost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
}: {
|
||
cost: number;
|
||
isHighVip?: boolean;
|
||
pointsEnabled: boolean;
|
||
}) {
|
||
if (!pointsEnabled) return null;
|
||
if (isHighVip) return "VIP";
|
||
if (cost <= 0) return "0";
|
||
return formatPointAmount(cost);
|
||
}
|
||
|
||
function ImageActionCreditBadge({
|
||
className,
|
||
compact = false,
|
||
label,
|
||
}: ImageActionCreditBadgeProps) {
|
||
if (!label) return null;
|
||
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"inline-flex shrink-0 items-center gap-1 rounded-md bg-slate-900 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-white shadow-sm dark:bg-white dark:text-slate-950",
|
||
compact
|
||
? "pointer-events-none absolute -top-8 left-1/2 z-20 -translate-x-1/2 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100"
|
||
: "ml-auto",
|
||
className
|
||
)}
|
||
title={label}
|
||
>
|
||
<Zap className="h-3 w-3 fill-current" />
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
type AngleEditPanelMode = "subject" | "camera";
|
||
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
|
||
type CropInteractionState = {
|
||
handle: CropInteractionHandle;
|
||
pointerId: number;
|
||
startPoint: BoxCutoutPoint;
|
||
startRect: NormalizedCropRect;
|
||
};
|
||
|
||
type LocalEditRegionInteractionState = {
|
||
handle: CropInteractionHandle;
|
||
pointerId: number;
|
||
regionId: string;
|
||
startPoint: BoxCutoutPoint;
|
||
startRect: NormalizedCropRect;
|
||
};
|
||
|
||
type LocalEditPromptCompositionSnapshot = {
|
||
end: number;
|
||
start: number;
|
||
value: string;
|
||
};
|
||
|
||
type AnglePreviewDragState = {
|
||
pointerId: number;
|
||
startX: number;
|
||
startY: number;
|
||
startHorizontalAngle: number;
|
||
startVerticalAngle: number;
|
||
};
|
||
|
||
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
|
||
const BOX_CUTOUT_MIN_EDGE_PX = 24;
|
||
const isLocalEditControlTarget = (target: EventTarget | null) =>
|
||
target instanceof HTMLElement &&
|
||
Boolean(target.closest('[data-local-edit-control="true"]'));
|
||
const FLOATING_TOOLBAR_SCALE = 0.8;
|
||
const IMAGE_EDIT_PANEL_SCALE = 0.5;
|
||
const ANGLE_PANEL_SCALE = 0.65;
|
||
const ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER = 0.72;
|
||
const ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER = 0.46;
|
||
const ANGLE_PANEL_SOURCE_WIDTH = 520;
|
||
const ANGLE_PANEL_SOURCE_HEIGHT = 700;
|
||
const parseAttachmentFilename = (contentDisposition: string | null) => {
|
||
if (!contentDisposition) return "";
|
||
const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||
if (encodedMatch?.[1]) {
|
||
try {
|
||
return decodeURIComponent(encodedMatch[1]);
|
||
} catch (_error) {
|
||
return encodedMatch[1];
|
||
}
|
||
}
|
||
const quotedMatch = contentDisposition.match(/filename="([^"]+)"/i);
|
||
if (quotedMatch?.[1]) return quotedMatch[1];
|
||
const plainMatch = contentDisposition.match(/filename=([^;]+)/i);
|
||
return plainMatch?.[1]?.trim() || "";
|
||
};
|
||
const ANGLE_PANEL_PREVIEW_SIZE = 392;
|
||
const ANGLE_PANEL_CAMERA_SCENE_SIZE = 384;
|
||
const ANGLE_PANEL_WIREFRAME_RADIUS = 132;
|
||
const ANGLE_PANEL_WIREFRAME_DIAMETER = ANGLE_PANEL_WIREFRAME_RADIUS * 2;
|
||
const ANGLE_PREVIEW_VERTICAL_RING_ROTATIONS = [
|
||
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165,
|
||
];
|
||
const ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS = [
|
||
-78, -56, -34, -14, 0, 14, 34, 56, 78,
|
||
];
|
||
const ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58];
|
||
const normalizeAnglePanelHorizontalAngle = (value: number) => {
|
||
if (!Number.isFinite(value)) return 0;
|
||
return value < 0 ? 360 + value : value;
|
||
};
|
||
|
||
const mapAnglePanelZoomToApi = (value: number) => {
|
||
const safeValue = Math.max(
|
||
0,
|
||
Math.min(100, Number.isFinite(value) ? value : 50)
|
||
);
|
||
return Number((1 + (safeValue / 100) * 8).toFixed(1));
|
||
};
|
||
|
||
const formatAnglePanelZoomLabel = (value: number) => {
|
||
if (value <= 32) return "远景";
|
||
if (value >= 68) return "近景";
|
||
return "中等";
|
||
};
|
||
|
||
function AngleEditPanelSlider({
|
||
label,
|
||
max,
|
||
min,
|
||
onChange,
|
||
step = 1,
|
||
value,
|
||
valueLabel,
|
||
}: {
|
||
label: string;
|
||
max: number;
|
||
min: number;
|
||
onChange: (value: number) => void;
|
||
step?: number;
|
||
value: number;
|
||
valueLabel: string;
|
||
}) {
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<span className="text-[17px] font-semibold tracking-[0] text-[#26272b]">
|
||
{label}
|
||
</span>
|
||
<span className="text-[16px] font-medium tabular-nums text-[#7d7f86]">
|
||
{valueLabel}
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
className="w-full"
|
||
max={max}
|
||
min={min}
|
||
onValueChange={([nextValue]) => onChange(nextValue ?? value)}
|
||
rangeClassName="bg-[#2d3035]"
|
||
step={step}
|
||
thumbClassName="h-5 w-5 border-[4px] border-[#2d3035] bg-white shadow-[0_2px_10px_rgba(24,24,27,0.14)]"
|
||
trackClassName="h-[4px] bg-[#e7e8ec]"
|
||
value={[value]}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AngleCameraModelGlyph() {
|
||
return (
|
||
<div className="absolute left-1/2 top-1/2 h-[26px] w-[26px] -translate-x-1/2 -translate-y-1/2 [transform-style:preserve-3d]">
|
||
<div className="absolute left-[3px] top-[8px] h-[13px] w-[18px] rounded-[5px] border border-[#6a707a] bg-[linear-gradient(180deg,#f9fafc,#dce2ea)] shadow-[0_2px_6px_rgba(15,23,42,0.12)]" />
|
||
<div className="absolute left-[6px] top-[5px] h-[5px] w-[8px] rounded-[3px] border border-[#6f7680] bg-[linear-gradient(180deg,#ffffff,#e1e6ee)]" />
|
||
<div className="absolute left-[11px] top-[11px] h-[7px] w-[7px] rounded-full border border-[#5d636d] bg-[radial-gradient(circle_at_35%_35%,#fefefe_0%,#d7dde6_38%,#8c95a1_65%,#5c6470_100%)]" />
|
||
<div className="absolute left-[18px] top-[11px] h-[2px] w-[2px] rounded-full bg-[#69707b]" />
|
||
<div className="absolute left-[6px] top-[10px] h-[9px] w-[2px] rounded-full bg-[#c4ccd7]" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const clampNumber = (value: number, min: number, max: number) =>
|
||
Math.min(max, Math.max(min, value));
|
||
|
||
const fitSizeWithinBox = ({
|
||
maxHeight,
|
||
maxWidth,
|
||
minHeight = 72,
|
||
minWidth = 72,
|
||
sourceHeight,
|
||
sourceWidth,
|
||
}: {
|
||
maxHeight: number;
|
||
maxWidth: number;
|
||
minHeight?: number;
|
||
minWidth?: number;
|
||
sourceHeight: number;
|
||
sourceWidth: number;
|
||
}) => {
|
||
const safeSourceWidth = sourceWidth > 0 ? sourceWidth : maxWidth;
|
||
const safeSourceHeight = sourceHeight > 0 ? sourceHeight : maxHeight;
|
||
let scale = Math.min(maxWidth / safeSourceWidth, maxHeight / safeSourceHeight);
|
||
let fittedWidth = safeSourceWidth * scale;
|
||
let fittedHeight = safeSourceHeight * scale;
|
||
|
||
if (fittedWidth < minWidth) {
|
||
scale = minWidth / safeSourceWidth;
|
||
fittedWidth = safeSourceWidth * scale;
|
||
fittedHeight = safeSourceHeight * scale;
|
||
}
|
||
|
||
if (fittedHeight < minHeight) {
|
||
scale = minHeight / safeSourceHeight;
|
||
fittedWidth = safeSourceWidth * scale;
|
||
fittedHeight = safeSourceHeight * scale;
|
||
}
|
||
|
||
if (fittedWidth > maxWidth || fittedHeight > maxHeight) {
|
||
scale = Math.min(maxWidth / fittedWidth, maxHeight / fittedHeight);
|
||
fittedWidth *= scale;
|
||
fittedHeight *= scale;
|
||
}
|
||
|
||
return {
|
||
width: Math.round(fittedWidth),
|
||
height: Math.round(fittedHeight),
|
||
};
|
||
};
|
||
|
||
const normalizeTextEditItems = (
|
||
items: ImageTextEditItem[] | undefined
|
||
): ImageTextEditItem[] => {
|
||
if (!items || items.length === 0) return [];
|
||
|
||
return items
|
||
.map((item, index) => {
|
||
const originalText = (item.originalText || "").trim();
|
||
if (!originalText) return null;
|
||
|
||
return {
|
||
id: item.id || String(index + 1),
|
||
originalText,
|
||
editedText: (item.editedText || item.originalText || "").trim(),
|
||
textColor: normalizeOptionalHexColor(item.textColor),
|
||
confidence: item.confidence,
|
||
} as ImageTextEditItem;
|
||
})
|
||
.filter((item): item is ImageTextEditItem => !!item);
|
||
};
|
||
|
||
const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
|
||
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
|
||
const CANVAS_3D_POLL_INTERVAL_MS = 2000;
|
||
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
|
||
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
|
||
const AI_WASH_TASK_POLL_LIMIT = 80;
|
||
const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId(
|
||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels
|
||
);
|
||
const DEFAULT_CANVAS_WORLD_MODEL = getDefaultThreeDModelAliasId(
|
||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.worldModels
|
||
);
|
||
|
||
const sleep = (ms: number) =>
|
||
new Promise((resolve) => window.setTimeout(resolve, ms));
|
||
|
||
function getAiWashPresetControl(
|
||
preset: AiWashPreset | null | undefined,
|
||
controlId: AiWashControlId
|
||
): AiWashControl | null {
|
||
if (!preset) return null;
|
||
return preset.controls.find((control) => control.id === controlId) || null;
|
||
}
|
||
|
||
function getAiWashControlLevelByIndex(
|
||
control: AiWashControl | null,
|
||
selectedIndex: number
|
||
): AiWashControlLevel | null {
|
||
if (!control || control.levels.length === 0) return null;
|
||
const clampedIndex = Math.max(
|
||
0,
|
||
Math.min(control.levels.length - 1, Math.floor(selectedIndex))
|
||
);
|
||
return control.levels[clampedIndex] || control.levels[0] || null;
|
||
}
|
||
|
||
function composeAiWashQuickPrompt(params: {
|
||
preset: AiWashPreset;
|
||
selectedLevelIndexByControl: Record<string, number>;
|
||
}) {
|
||
const promptParts = [params.preset.prompt.trim()].filter(Boolean);
|
||
|
||
for (const controlId of AI_WASH_CONTROL_ORDER) {
|
||
const level = getAiWashControlLevelByIndex(
|
||
getAiWashPresetControl(params.preset, controlId),
|
||
params.selectedLevelIndexByControl[controlId] ?? 0
|
||
);
|
||
const fragment = String(level?.promptFragment || "").trim();
|
||
if (fragment) {
|
||
promptParts.push(fragment);
|
||
}
|
||
}
|
||
|
||
return promptParts.join("\n");
|
||
}
|
||
|
||
function resolveAiWashQuickAdminModelId(params: {
|
||
preset: AiWashPreset;
|
||
selectedLevelIndexByControl: Record<string, number>;
|
||
}) {
|
||
for (const controlId of AI_WASH_CONTROL_ORDER) {
|
||
const level = getAiWashControlLevelByIndex(
|
||
getAiWashPresetControl(params.preset, controlId),
|
||
params.selectedLevelIndexByControl[controlId] ?? 0
|
||
);
|
||
const levelModelId = String(level?.modelId || "").trim();
|
||
if (levelModelId) return levelModelId;
|
||
}
|
||
return String(params.preset.modelId || "").trim();
|
||
}
|
||
|
||
function getAiWashStatusLabel(status?: AiWashResultStatus) {
|
||
switch (status) {
|
||
case "submitting":
|
||
return "提交中";
|
||
case "running":
|
||
return "生成中";
|
||
case "succeeded":
|
||
return "已完成";
|
||
case "failed":
|
||
return "失败";
|
||
case "idle":
|
||
default:
|
||
return "待生成";
|
||
}
|
||
}
|
||
|
||
async function pollAiWashQuickTask(taskId: string) {
|
||
for (let attempt = 0; attempt < AI_WASH_TASK_POLL_LIMIT; attempt += 1) {
|
||
const response = await fetch(
|
||
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`
|
||
);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || payload?.success !== true || !payload?.task) {
|
||
throw new Error(payload?.error || "查询 AI洗图任务失败");
|
||
}
|
||
const task = payload.task as {
|
||
status: string;
|
||
error?: string | null;
|
||
result?: {
|
||
images?: Array<{ thumbnailUrl?: string; originalUrl?: string }>;
|
||
model?: string;
|
||
modelRef?: string;
|
||
prompt?: string;
|
||
} | null;
|
||
};
|
||
const status = String(task.status || "").toLowerCase();
|
||
if (status === "succeeded" || status === "completed") {
|
||
const image = task.result?.images?.[0];
|
||
if (!image?.originalUrl) {
|
||
throw new Error("AI洗图已完成,但没有拿到结果图片");
|
||
}
|
||
return {
|
||
imageUrl: image.originalUrl,
|
||
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
|
||
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
|
||
prompt: String(task.result?.prompt || "").trim(),
|
||
};
|
||
}
|
||
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||
throw new Error(task.error || "AI洗图任务失败");
|
||
}
|
||
await sleep(AI_WASH_TASK_POLL_INTERVAL_MS);
|
||
}
|
||
|
||
throw new Error("AI洗图任务轮询超时,请稍后重试");
|
||
}
|
||
|
||
const isTerminal3DFailureStatus = (status?: string | null) =>
|
||
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
|
||
|
||
function getChatAttachmentExtension(mimeType: string) {
|
||
const normalized = mimeType.toLowerCase();
|
||
if (normalized.includes("png")) return "png";
|
||
if (normalized.includes("webp")) return "webp";
|
||
if (normalized.includes("gif")) return "gif";
|
||
if (normalized.includes("svg")) return "svg";
|
||
return "jpg";
|
||
}
|
||
|
||
function normalizeChatAttachmentFileName(name: string, mimeType: string) {
|
||
const baseName =
|
||
String(name || "canvas-image")
|
||
.trim()
|
||
.replace(/\.[^.]+$/, "")
|
||
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_-]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 48) || "canvas-image";
|
||
return `${baseName}-${Date.now()}.${getChatAttachmentExtension(mimeType)}`;
|
||
}
|
||
|
||
async function uploadChatAttachmentBlob(blob: Blob, displayName: string) {
|
||
const uploadBlob = blob.type ? blob : new Blob([blob], { type: "image/jpeg" });
|
||
|
||
if (!uploadBlob.type.startsWith("image/")) {
|
||
throw new Error("只能添加图片到对话");
|
||
}
|
||
|
||
if (!["image/jpeg", "image/jpg", "image/png", "image/webp"].includes(uploadBlob.type)) {
|
||
throw new Error("暂只支持 JPG、PNG、WebP 图片添加到对话");
|
||
}
|
||
|
||
if (uploadBlob.size > CHAT_ATTACHMENT_MAX_UPLOAD_BYTES) {
|
||
throw new Error("图片过大,请先压缩后再添加到对话");
|
||
}
|
||
|
||
const formData = new FormData();
|
||
const contentType = uploadBlob.type || "image/jpeg";
|
||
formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType));
|
||
formData.append("purpose", "canvas-upload");
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
let payload: any = null;
|
||
try {
|
||
payload = await response.json();
|
||
} catch {
|
||
payload = null;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(payload?.error || "图片上传失败,请稍后重试");
|
||
}
|
||
|
||
const uploadedUrl = String(payload?.url || "").trim();
|
||
if (!isHttpImageUrl(uploadedUrl)) {
|
||
throw new Error("图片上传后未返回可用地址");
|
||
}
|
||
|
||
return {
|
||
url: uploadedUrl,
|
||
contentType,
|
||
};
|
||
}
|
||
|
||
async function resolveChatAttachmentFromImageSrc(src: string, displayName: string) {
|
||
const trimmedSrc = String(src || "").trim();
|
||
if (!trimmedSrc) {
|
||
throw new Error("当前图片地址不可用");
|
||
}
|
||
|
||
if (isHttpImageUrl(trimmedSrc)) {
|
||
return {
|
||
url: trimmedSrc,
|
||
contentType: "image/jpeg",
|
||
};
|
||
}
|
||
|
||
if (trimmedSrc.startsWith("data:image/")) {
|
||
if (trimmedSrc.length > CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH) {
|
||
throw new Error("图片数据过大,请先下载压缩后再添加到对话");
|
||
}
|
||
const blob = await fetch(trimmedSrc).then((response) => response.blob());
|
||
return uploadChatAttachmentBlob(blob, displayName);
|
||
}
|
||
|
||
if (trimmedSrc.startsWith("blob:")) {
|
||
const blob = await fetch(trimmedSrc).then((response) => response.blob());
|
||
return uploadChatAttachmentBlob(blob, displayName);
|
||
}
|
||
|
||
throw new Error("当前图片地址不可直接加入对话,请重新上传图片");
|
||
}
|
||
|
||
async function renderAndUploadFlippedImage(
|
||
imageSrc: string,
|
||
action: Exclude<FlipRotateEditAction, "reset">,
|
||
displayName: string
|
||
) {
|
||
const sourceUrl = buildImageProxyUrl(String(imageSrc || "").trim(), {
|
||
delivery: "proxy",
|
||
});
|
||
const response = await fetch(sourceUrl);
|
||
if (!response.ok) {
|
||
throw new Error("读取原图失败,请稍后重试");
|
||
}
|
||
|
||
const sourceBlob = await response.blob();
|
||
const objectUrl = URL.createObjectURL(sourceBlob);
|
||
|
||
try {
|
||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => reject(new Error("图片加载失败"));
|
||
img.src = objectUrl;
|
||
});
|
||
|
||
const width = image.naturalWidth || image.width;
|
||
const height = image.naturalHeight || image.height;
|
||
if (!width || !height) {
|
||
throw new Error("图片尺寸无效,无法执行翻转");
|
||
}
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) {
|
||
throw new Error("无法创建画布上下文");
|
||
}
|
||
|
||
ctx.save();
|
||
if (action === "horizontal") {
|
||
ctx.translate(width, 0);
|
||
ctx.scale(-1, 1);
|
||
} else {
|
||
ctx.translate(0, height);
|
||
ctx.scale(1, -1);
|
||
}
|
||
ctx.drawImage(image, 0, 0, width, height);
|
||
ctx.restore();
|
||
|
||
const outputType = ["image/png", "image/jpeg", "image/webp"].includes(
|
||
sourceBlob.type
|
||
)
|
||
? sourceBlob.type
|
||
: "image/png";
|
||
|
||
const transformedBlob = await new Promise<Blob>((resolve, reject) => {
|
||
canvas.toBlob(
|
||
(blob) => {
|
||
if (blob) {
|
||
resolve(blob);
|
||
return;
|
||
}
|
||
reject(new Error("导出翻转图片失败"));
|
||
},
|
||
outputType,
|
||
outputType === "image/jpeg" || outputType === "image/webp"
|
||
? 0.96
|
||
: undefined
|
||
);
|
||
});
|
||
|
||
const uploadResult = await uploadChatAttachmentBlob(
|
||
transformedBlob,
|
||
displayName
|
||
);
|
||
return uploadResult.url;
|
||
} finally {
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
}
|
||
|
||
async function renderAndUploadCroppedImage(
|
||
imageSrc: string,
|
||
cropRect: NormalizedCropRect,
|
||
displayName: string
|
||
) {
|
||
const sourceUrl = buildImageProxyUrl(String(imageSrc || "").trim(), {
|
||
delivery: "proxy",
|
||
});
|
||
const response = await fetch(sourceUrl);
|
||
if (!response.ok) {
|
||
throw new Error("读取原图失败,请稍后重试");
|
||
}
|
||
|
||
const sourceBlob = await response.blob();
|
||
const objectUrl = URL.createObjectURL(sourceBlob);
|
||
|
||
try {
|
||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => reject(new Error("图片加载失败"));
|
||
img.src = objectUrl;
|
||
});
|
||
|
||
const naturalWidth = image.naturalWidth || image.width;
|
||
const naturalHeight = image.naturalHeight || image.height;
|
||
if (!naturalWidth || !naturalHeight) {
|
||
throw new Error("图片尺寸无效,无法执行裁剪");
|
||
}
|
||
|
||
const left = Math.round(clampNumber(cropRect.x, 0, 1) * naturalWidth);
|
||
const top = Math.round(clampNumber(cropRect.y, 0, 1) * naturalHeight);
|
||
const width = Math.max(
|
||
BOX_CUTOUT_MIN_EDGE_PX,
|
||
Math.round(clampNumber(cropRect.width, 0, 1) * naturalWidth)
|
||
);
|
||
const height = Math.max(
|
||
BOX_CUTOUT_MIN_EDGE_PX,
|
||
Math.round(clampNumber(cropRect.height, 0, 1) * naturalHeight)
|
||
);
|
||
|
||
const safeWidth = Math.min(width, naturalWidth - left);
|
||
const safeHeight = Math.min(height, naturalHeight - top);
|
||
if (safeWidth <= 0 || safeHeight <= 0) {
|
||
throw new Error("裁剪区域无效,请重新调整");
|
||
}
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = safeWidth;
|
||
canvas.height = safeHeight;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) {
|
||
throw new Error("无法创建画布上下文");
|
||
}
|
||
|
||
ctx.drawImage(
|
||
image,
|
||
left,
|
||
top,
|
||
safeWidth,
|
||
safeHeight,
|
||
0,
|
||
0,
|
||
safeWidth,
|
||
safeHeight
|
||
);
|
||
|
||
const outputType = ["image/png", "image/jpeg", "image/webp"].includes(
|
||
sourceBlob.type
|
||
)
|
||
? sourceBlob.type
|
||
: "image/png";
|
||
|
||
const croppedBlob = await new Promise<Blob>((resolve, reject) => {
|
||
canvas.toBlob(
|
||
(blob) => {
|
||
if (blob) {
|
||
resolve(blob);
|
||
return;
|
||
}
|
||
reject(new Error("导出裁剪图片失败"));
|
||
},
|
||
outputType,
|
||
outputType === "image/jpeg" || outputType === "image/webp"
|
||
? 0.96
|
||
: undefined
|
||
);
|
||
});
|
||
|
||
const uploadResult = await uploadChatAttachmentBlob(croppedBlob, displayName);
|
||
return {
|
||
url: uploadResult.url,
|
||
width: safeWidth,
|
||
height: safeHeight,
|
||
};
|
||
} finally {
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在图片上绘制标记点,返回带标记的图片 base64
|
||
*/
|
||
async function drawMarkerOnImage(
|
||
imageUrl: string,
|
||
clickX: number,
|
||
clickY: number
|
||
): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
const loadUrl = buildImageProxyUrl(imageUrl, { delivery: "proxy" });
|
||
|
||
img.onload = () => {
|
||
const canvas = document.createElement("canvas");
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) {
|
||
reject(new Error("无法创建 canvas"));
|
||
return;
|
||
}
|
||
|
||
// 压缩图片尺寸(最大 800px)
|
||
const maxSize = 800;
|
||
let width = img.width;
|
||
let height = img.height;
|
||
|
||
if (width > maxSize || height > maxSize) {
|
||
if (width > height) {
|
||
height = (height / width) * maxSize;
|
||
width = maxSize;
|
||
} else {
|
||
width = (width / height) * maxSize;
|
||
height = maxSize;
|
||
}
|
||
}
|
||
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
// 计算标记位置
|
||
const markerX = clickX * width;
|
||
const markerY = clickY * height;
|
||
const markerRadius = Math.max(12, Math.min(width, height) * 0.025);
|
||
|
||
// 绘制红色标记
|
||
ctx.beginPath();
|
||
ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = "white";
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2);
|
||
ctx.fillStyle = "#FF0000";
|
||
ctx.fill();
|
||
|
||
// 绘制十字线
|
||
ctx.strokeStyle = "#FF0000";
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX - markerRadius * 2, markerY);
|
||
ctx.lineTo(markerX - markerRadius - 2, markerY);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX + markerRadius + 2, markerY);
|
||
ctx.lineTo(markerX + markerRadius * 2, markerY);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX, markerY - markerRadius * 2);
|
||
ctx.lineTo(markerX, markerY - markerRadius - 2);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX, markerY + markerRadius + 2);
|
||
ctx.lineTo(markerX, markerY + markerRadius * 2);
|
||
ctx.stroke();
|
||
|
||
resolve(canvas.toDataURL("image/jpeg", 0.85));
|
||
};
|
||
|
||
img.onerror = () => reject(new Error("图片加载失败"));
|
||
img.src = loadUrl;
|
||
});
|
||
}
|
||
|
||
interface ImageNodeProps {
|
||
node: CanvasNode;
|
||
isSelected: boolean;
|
||
isLayerSplitEnabled?: boolean;
|
||
batchProcessingEnabled?: boolean;
|
||
canvasToolbarConfig?: CanvasToolbarConfig;
|
||
angleEditEnabled?: boolean;
|
||
tripo3dEnabled?: boolean;
|
||
worldlabs3dEnabled?: boolean;
|
||
}
|
||
|
||
export function ImageNode({
|
||
node,
|
||
isSelected,
|
||
isLayerSplitEnabled = false,
|
||
batchProcessingEnabled = true,
|
||
canvasToolbarConfig,
|
||
angleEditEnabled = false,
|
||
tripo3dEnabled = false,
|
||
worldlabs3dEnabled = false,
|
||
}: ImageNodeProps) {
|
||
const router = useRouter();
|
||
const routeParams = useParams();
|
||
const canvasId = Array.isArray(routeParams?.id)
|
||
? routeParams.id[0] || ""
|
||
: typeof routeParams?.id === "string"
|
||
? routeParams.id
|
||
: "";
|
||
const data = node.data as ImageNodeData;
|
||
const { data: aiWashConfigPayload, isLoading: aiWashConfigLoading } =
|
||
useSWR<AiWashConfigResponse>("/api/ai-wash/config", fetcher);
|
||
const aiWashQuickConfig = useMemo(
|
||
() =>
|
||
normalizeAiWashConfig(
|
||
aiWashConfigPayload?.config || defaultAiWashConfig()
|
||
),
|
||
[aiWashConfigPayload?.config]
|
||
);
|
||
const aiWashQuickPresets = useMemo(
|
||
() => getEnabledAiWashPresets(aiWashQuickConfig).filter((preset) => preset.enabled),
|
||
[aiWashQuickConfig]
|
||
);
|
||
const imageAiWashQuickEnabled =
|
||
aiWashQuickConfig.enabled && aiWashQuickPresets.length > 0;
|
||
const aiWashQuickModeLabel =
|
||
aiWashQuickConfig.modeLabel || AI_WASH_DEFAULT_MODE_LABEL;
|
||
const {
|
||
data: aiWashModelOptionsPayload,
|
||
isLoading: aiWashModelOptionsLoading,
|
||
} = useSWR<AiWashModelOptionsResponse>(
|
||
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
|
||
fetcher
|
||
);
|
||
const { data: membershipSummary } = useSWR<MembershipSummaryResponse>(
|
||
"/api/user/membership-summary",
|
||
fetcher
|
||
);
|
||
const 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 selectedImageInlineActionCount =
|
||
getSelectedImageInlineActionCount(canvasToolbarConfig);
|
||
const selectedImageToolbarActionCandidates = [
|
||
{ key: "pickObject", enabled: imagePickObjectEnabled },
|
||
{ key: "enhance", enabled: imageEnhanceEnabled },
|
||
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
|
||
{ key: "aiWashQuick", enabled: imageAiWashQuickEnabled },
|
||
{ key: "textEdit", enabled: imageTextEditEnabled },
|
||
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
||
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
||
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
||
{ key: "resize", enabled: imageResizeEnabled },
|
||
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
||
{ key: "generate3dModel", enabled: imageGenerate3dModelEnabled },
|
||
{ key: "generate3dWorld", enabled: imageGenerate3dWorldEnabled },
|
||
{ key: "layerSplit", enabled: imageLayerSplitEnabled },
|
||
{ key: "extend", enabled: imageExtendEnabled },
|
||
{ key: "crop", enabled: imageCropEnabled },
|
||
{ key: "adjust", enabled: imageAdjustEnabled },
|
||
{ key: "flipRotate", enabled: imageFlipRotateEnabled },
|
||
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
|
||
{ key: "note", enabled: imageNoteEnabled },
|
||
] as const;
|
||
type SelectedImageToolbarActionKey =
|
||
(typeof selectedImageToolbarActionCandidates)[number]["key"];
|
||
const enabledSelectedImageToolbarActions =
|
||
selectedImageToolbarActionCandidates.filter((action) => action.enabled);
|
||
const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>(
|
||
enabledSelectedImageToolbarActions
|
||
.slice(0, selectedImageInlineActionCount)
|
||
.map((action) => action.key)
|
||
);
|
||
const selectedImageMoreActionKeys = new Set<SelectedImageToolbarActionKey>(
|
||
enabledSelectedImageToolbarActions
|
||
.slice(selectedImageInlineActionCount)
|
||
.map((action) => action.key)
|
||
);
|
||
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
|
||
selectedImageInlineActionKeys.has(key);
|
||
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
|
||
selectedImageMoreActionKeys.has(key);
|
||
const hasHoverImageMorePrimaryActions =
|
||
imageRemoveBackgroundEnabled ||
|
||
imageRemoveWatermarkEnabled ||
|
||
imageEnhanceEnabled ||
|
||
imageNoteEnabled ||
|
||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
||
imageResizeEnabled ||
|
||
imageMultiAngleEnabled ||
|
||
imagePromptRegenerateEnabled ||
|
||
imageTextEditEnabled ||
|
||
imageExtendEnabled ||
|
||
imageCropEnabled ||
|
||
imageAdjustEnabled ||
|
||
imageFlipRotateEnabled ||
|
||
imageRenameEnabled;
|
||
const hasHoverImageMoreSecondaryActions =
|
||
imageDownloadEnabled || imageDeleteEnabled;
|
||
const hasHoverImageMoreMenu =
|
||
imageNodeMoreEnabled &&
|
||
(hasHoverImageMorePrimaryActions || hasHoverImageMoreSecondaryActions);
|
||
const hasSelectedImageMorePrimaryActions =
|
||
selectedImageMoreActionKeys.size > 0;
|
||
const hasSelectedImageMoreMenu =
|
||
hasSelectedImageMorePrimaryActions ||
|
||
(imageNodeMoreEnabled && imageDeleteEnabled);
|
||
const hasSelectedImagePrimaryToolbarActions =
|
||
selectedImageInlineActionKeys.size > 0;
|
||
const hasSelectedImageSecondaryToolbarActions =
|
||
hasSelectedImageMoreMenu || imageDownloadEnabled;
|
||
const contentScale = useMemo(
|
||
() =>
|
||
getNodeContentScale(node.size, {
|
||
width: 220,
|
||
height: 220,
|
||
}),
|
||
[node.size.height, node.size.width]
|
||
);
|
||
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
|
||
const nodeContentScale = contentScale * viewportZoom;
|
||
const scaledContentStyle = useMemo(
|
||
() => getScaledNodeContentStyle(nodeContentScale, { allowUpscale: true }),
|
||
[nodeContentScale]
|
||
);
|
||
const selectedTitleStyle = useMemo<React.CSSProperties>(
|
||
() => ({
|
||
fontSize: 12,
|
||
lineHeight: "16px",
|
||
top: -28,
|
||
}),
|
||
[]
|
||
);
|
||
const detailProgressLabel =
|
||
typeof data.metadata?.detailProgressLabel === "string"
|
||
? data.metadata.detailProgressLabel.trim()
|
||
: "";
|
||
const generationProgress =
|
||
typeof data.metadata?.generationProgress === "number" &&
|
||
Number.isFinite(data.metadata.generationProgress)
|
||
? Math.max(0, Math.min(100, Math.round(data.metadata.generationProgress)))
|
||
: null;
|
||
const recentlyGeneratedAt =
|
||
typeof data.metadata?.recentlyGeneratedAt === "number" &&
|
||
Number.isFinite(data.metadata.recentlyGeneratedAt)
|
||
? data.metadata.recentlyGeneratedAt
|
||
: 0;
|
||
const isRecentlyGenerated =
|
||
Boolean(data.src) &&
|
||
recentlyGeneratedAt > 0 &&
|
||
Date.now() - recentlyGeneratedAt < 20_000;
|
||
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 [aiWashQuickPanelOpen, setAiWashQuickPanelOpen] = useState(false);
|
||
const [aiWashQuickPresetId, setAiWashQuickPresetId] = useState("");
|
||
const [aiWashQuickModelId, setAiWashQuickModelId] = useState("");
|
||
const [aiWashQuickLevels, setAiWashQuickLevels] = useState<
|
||
Record<string, number>
|
||
>({});
|
||
const [isSubmittingAiWashQuick, setIsSubmittingAiWashQuick] = useState(false);
|
||
const [aiWashQuickStatus, setAiWashQuickStatus] =
|
||
useState<AiWashResultStatus>("idle");
|
||
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
|
||
const [boxCutoutToolbarSize, setBoxCutoutToolbarSize] = useState({
|
||
width: 0,
|
||
height: 0,
|
||
});
|
||
const [boxCutoutMode, setBoxCutoutMode] = useState(false);
|
||
const [isBoxCutoutDrawing, setIsBoxCutoutDrawing] = useState(false);
|
||
const [boxCutoutSelection, setBoxCutoutSelection] =
|
||
useState<NormalizedCropRect | null>(null);
|
||
const [boxCutoutDragStart, setBoxCutoutDragStart] =
|
||
useState<BoxCutoutPoint | null>(null);
|
||
const [boxCutoutDragEnd, setBoxCutoutDragEnd] =
|
||
useState<BoxCutoutPoint | null>(null);
|
||
const [localEditMode, setLocalEditMode] = useState(false);
|
||
const [localEditRegions, setLocalEditRegions] = useState<LocalEditRegion[]>(
|
||
() => getLocalEditRegionsFromMetadata(data.metadata)
|
||
);
|
||
const [localEditPromptDrafts, setLocalEditPromptDrafts] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [isLocalEditDrawing, setIsLocalEditDrawing] = useState(false);
|
||
const [localEditDragStart, setLocalEditDragStart] =
|
||
useState<BoxCutoutPoint | null>(null);
|
||
const [localEditDragEnd, setLocalEditDragEnd] =
|
||
useState<BoxCutoutPoint | null>(null);
|
||
const [localEditRegionInteraction, setLocalEditRegionInteraction] =
|
||
useState<LocalEditRegionInteractionState | null>(null);
|
||
const [editingLocalEditRegionId, setEditingLocalEditRegionId] =
|
||
useState<string | null>(null);
|
||
const [isSubmittingLocalEdit, setIsSubmittingLocalEdit] = useState(false);
|
||
const [
|
||
uploadingLocalEditReferenceRegionId,
|
||
setUploadingLocalEditReferenceRegionId,
|
||
] = useState<string | null>(null);
|
||
const anglePreviewDragRef = useRef<AnglePreviewDragState | null>(null);
|
||
const activeImageAdjustments = imageAdjustPanelOpen
|
||
? imageAdjustmentsDraft
|
||
: storedImageAdjustments;
|
||
const activeImageAdjustmentsStyle = useMemo(
|
||
() => buildImageAdjustmentFilterStyle(activeImageAdjustments),
|
||
[activeImageAdjustments]
|
||
);
|
||
const hasActiveImageAdjustments = !isDefaultImageAdjustments(
|
||
activeImageAdjustments
|
||
);
|
||
const selectedAiWashQuickPreset = useMemo(
|
||
() =>
|
||
aiWashQuickPresets.find((preset) => preset.id === aiWashQuickPresetId) ||
|
||
aiWashQuickPresets.find((preset) => preset.isDefault) ||
|
||
aiWashQuickPresets[0] ||
|
||
null,
|
||
[aiWashQuickPresetId, aiWashQuickPresets]
|
||
);
|
||
const aiWashQuickControls = useMemo(
|
||
() =>
|
||
AI_WASH_CONTROL_ORDER.map((controlId) =>
|
||
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
|
||
).filter(Boolean) as AiWashControl[],
|
||
[selectedAiWashQuickPreset]
|
||
);
|
||
const aiWashQuickSelectableModels = useMemo(
|
||
() => aiWashModelOptionsPayload?.models || [],
|
||
[aiWashModelOptionsPayload?.models]
|
||
);
|
||
const aiWashQuickLockedModels = useMemo(
|
||
() => aiWashConfigPayload?.lockedModels || [],
|
||
[aiWashConfigPayload?.lockedModels]
|
||
);
|
||
const aiWashQuickModelOptionByRef = useMemo(() => {
|
||
const map = new Map<string, AiWashModelOption>();
|
||
[...aiWashQuickLockedModels, ...aiWashQuickSelectableModels].forEach((model) => {
|
||
map.set(model.id, model);
|
||
map.set(model.modelId, model);
|
||
});
|
||
return map;
|
||
}, [aiWashQuickLockedModels, aiWashQuickSelectableModels]);
|
||
const aiWashQuickAdminModelRef = useMemo(
|
||
() =>
|
||
selectedAiWashQuickPreset
|
||
? resolveAiWashQuickAdminModelId({
|
||
preset: selectedAiWashQuickPreset,
|
||
selectedLevelIndexByControl: aiWashQuickLevels,
|
||
})
|
||
: "",
|
||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||
);
|
||
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
|
||
const effectiveAiWashQuickModelRef =
|
||
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
|
||
const selectedAiWashQuickModel = useMemo(
|
||
() =>
|
||
aiWashQuickModelOptionByRef.get(effectiveAiWashQuickModelRef) ||
|
||
aiWashQuickModelOptionByRef.get(aiWashQuickModelId) ||
|
||
null,
|
||
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
|
||
);
|
||
const aiWashQuickPrompt = useMemo(
|
||
() =>
|
||
selectedAiWashQuickPreset
|
||
? composeAiWashQuickPrompt({
|
||
preset: selectedAiWashQuickPreset,
|
||
selectedLevelIndexByControl: aiWashQuickLevels,
|
||
})
|
||
: "",
|
||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!batchProcessingEnabled) {
|
||
setSceneFusionDialogOpen(false);
|
||
}
|
||
}, [batchProcessingEnabled]);
|
||
useEffect(() => {
|
||
if (aiWashQuickPresets.length === 0) {
|
||
setAiWashQuickPresetId("");
|
||
return;
|
||
}
|
||
setAiWashQuickPresetId((current) => {
|
||
if (aiWashQuickPresets.some((preset) => preset.id === current)) {
|
||
return current;
|
||
}
|
||
return (
|
||
aiWashQuickPresets.find((preset) => preset.isDefault)?.id ||
|
||
aiWashQuickPresets[0]?.id ||
|
||
""
|
||
);
|
||
});
|
||
}, [aiWashQuickPresets]);
|
||
useEffect(() => {
|
||
if (!selectedAiWashQuickPreset) {
|
||
setAiWashQuickLevels({});
|
||
return;
|
||
}
|
||
setAiWashQuickLevels(
|
||
AI_WASH_CONTROL_ORDER.reduce<Record<string, number>>(
|
||
(accumulator, controlId) => {
|
||
accumulator[controlId] =
|
||
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
|
||
?.defaultLevel ?? 0;
|
||
return accumulator;
|
||
},
|
||
{}
|
||
)
|
||
);
|
||
setAiWashQuickStatus("idle");
|
||
}, [selectedAiWashQuickPreset]);
|
||
useEffect(() => {
|
||
const fallbackModelId =
|
||
String(aiWashModelOptionsPayload?.defaultModelId || "").trim() ||
|
||
aiWashQuickSelectableModels.find((model) => model.isDefault)?.id ||
|
||
aiWashQuickSelectableModels[0]?.id ||
|
||
"";
|
||
setAiWashQuickModelId((current) => {
|
||
if (
|
||
current &&
|
||
aiWashQuickSelectableModels.some((model) => model.id === current)
|
||
) {
|
||
return current;
|
||
}
|
||
return fallbackModelId;
|
||
});
|
||
}, [aiWashModelOptionsPayload?.defaultModelId, aiWashQuickSelectableModels]);
|
||
useEffect(() => {
|
||
if (!imageAiWashQuickEnabled || !isSelected) {
|
||
setAiWashQuickPanelOpen(false);
|
||
}
|
||
}, [imageAiWashQuickEnabled, isSelected]);
|
||
useEffect(() => {
|
||
if (!imageAdjustPanelOpen) return;
|
||
setImageAdjustmentsDraft(storedImageAdjustments);
|
||
}, [imageAdjustPanelOpen, storedImageAdjustments, data.src]);
|
||
useEffect(() => {
|
||
if (!cropPanelOpen) return;
|
||
if (cropRect) return;
|
||
const centerX = 0.5;
|
||
const centerY = 0.5;
|
||
const preset = getCropPresetById(cropPresetId);
|
||
if (preset) {
|
||
setCropRect(createCenteredCropRectForAspect(preset.ratio, centerX, centerY));
|
||
return;
|
||
}
|
||
setCropRect({
|
||
x: 0,
|
||
y: 0,
|
||
width: 1,
|
||
height: 1,
|
||
});
|
||
}, [cropPanelOpen, cropPresetId, cropRect]);
|
||
useEffect(() => {
|
||
if (!imageAdjustPanelOpen || typeof window === "undefined") return;
|
||
if (areImageAdjustmentsEqual(imageAdjustmentsDraft, storedImageAdjustments)) {
|
||
return;
|
||
}
|
||
|
||
const timer = window.setTimeout(() => {
|
||
const nextMetadata =
|
||
data.metadata && typeof data.metadata === "object"
|
||
? { ...(data.metadata as Record<string, unknown>) }
|
||
: {};
|
||
|
||
if (isDefaultImageAdjustments(imageAdjustmentsDraft)) {
|
||
delete nextMetadata.imageAdjustments;
|
||
delete nextMetadata.imageAdjustmentsUpdatedAt;
|
||
} else {
|
||
nextMetadata.imageAdjustments = imageAdjustmentsDraft;
|
||
nextMetadata.imageAdjustmentsUpdatedAt = new Date().toISOString();
|
||
}
|
||
|
||
useCanvasStore.getState().updateNode(node.id, {
|
||
data: {
|
||
...data,
|
||
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
|
||
},
|
||
});
|
||
}, 140);
|
||
|
||
return () => window.clearTimeout(timer);
|
||
}, [
|
||
data,
|
||
imageAdjustPanelOpen,
|
||
imageAdjustmentsDraft,
|
||
node.id,
|
||
storedImageAdjustments,
|
||
]);
|
||
useEffect(() => {
|
||
if (!anglePanelOpen) return;
|
||
|
||
const savedSettings =
|
||
data.metadata &&
|
||
typeof data.metadata === "object" &&
|
||
!Array.isArray(data.metadata)
|
||
? (data.metadata as {
|
||
angleEditSettings?: {
|
||
mode?: AngleEditPanelMode;
|
||
horizontalAngle?: number;
|
||
verticalAngle?: number;
|
||
zoom?: number;
|
||
};
|
||
}).angleEditSettings
|
||
: undefined;
|
||
|
||
if (!savedSettings) return;
|
||
|
||
if (savedSettings.mode === "subject" || savedSettings.mode === "camera") {
|
||
setAnglePanelMode(savedSettings.mode);
|
||
}
|
||
if (typeof savedSettings.horizontalAngle === "number") {
|
||
setAngleHorizontalAngle(clampNumber(savedSettings.horizontalAngle, -180, 180));
|
||
}
|
||
if (typeof savedSettings.verticalAngle === "number") {
|
||
setAngleVerticalAngle(clampNumber(savedSettings.verticalAngle, -30, 60));
|
||
}
|
||
if (typeof savedSettings.zoom === "number") {
|
||
setAngleZoom(clampNumber(savedSettings.zoom, 0, 100));
|
||
}
|
||
}, [anglePanelOpen, data.metadata]);
|
||
useEffect(() => {
|
||
if (!anglePanelOpen || typeof window === "undefined") {
|
||
setAnglePanelOpening(false);
|
||
return;
|
||
}
|
||
|
||
setAnglePanelOpening(true);
|
||
const timer = window.setTimeout(() => {
|
||
setAnglePanelOpening(false);
|
||
}, 560);
|
||
|
||
return () => window.clearTimeout(timer);
|
||
}, [anglePanelOpen]);
|
||
useEffect(() => {
|
||
const nextDefaultTripoModelId =
|
||
getDefaultThreeDModelAliasId(publicModelAliasConfig.tripoModels) ||
|
||
DEFAULT_CANVAS_TRIPO_MODEL ||
|
||
"tripo-standard";
|
||
if (!tripoModelOptions.some((entry) => entry.id === selectedTripoModelVersion)) {
|
||
setSelectedTripoModelVersion(nextDefaultTripoModelId);
|
||
}
|
||
}, [
|
||
publicModelAliasConfig.tripoModels,
|
||
selectedTripoModelVersion,
|
||
tripoModelOptions,
|
||
]);
|
||
useEffect(() => {
|
||
const nextDefaultWorldModelId =
|
||
getDefaultThreeDModelAliasId(publicModelAliasConfig.worldModels) ||
|
||
DEFAULT_CANVAS_WORLD_MODEL ||
|
||
"world-standard";
|
||
if (!worldModelOptions.some((entry) => entry.id === selectedWorldModelVersion)) {
|
||
setSelectedWorldModelVersion(nextDefaultWorldModelId);
|
||
}
|
||
}, [
|
||
publicModelAliasConfig.worldModels,
|
||
selectedWorldModelVersion,
|
||
worldModelOptions,
|
||
]);
|
||
const [boxCutoutMetrics, setBoxCutoutMetrics] =
|
||
useState<ImageDisplayMetrics | null>(null);
|
||
|
||
// 文件输入的 ref
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [localEditPromptPortalFrame, setLocalEditPromptPortalFrame] = useState<{
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
} | null>(null);
|
||
const displayImageRef = useRef<HTMLImageElement>(null);
|
||
const compareFrameRef = useRef<HTMLDivElement>(null);
|
||
const cropOverlayRef = useRef<HTMLDivElement>(null);
|
||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||
const boxCutoutToolbarRef = useRef<HTMLDivElement>(null);
|
||
const localEditPromptInputRefs = useRef<
|
||
Record<string, HTMLInputElement | null>
|
||
>({});
|
||
const localEditPromptDraftRefs = useRef<Record<string, string>>({});
|
||
const localEditPromptCompositionStartRefs = useRef<
|
||
Record<string, LocalEditPromptCompositionSnapshot | undefined>
|
||
>({});
|
||
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
|
||
|
||
const 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 pointsSystemConfig = useMemo(
|
||
() =>
|
||
normalizePointsSystemConfig(
|
||
membershipSummary?.pointsSystemConfig || defaultPointsSystemConfig
|
||
),
|
||
[membershipSummary?.pointsSystemConfig]
|
||
);
|
||
const pointsEnabled = pointsSystemConfig.enabled === true;
|
||
const isHighVip = membershipSummary?.isHighVip === true;
|
||
const tripoImageToModelCreditCost = useMemo(
|
||
() =>
|
||
get3DTaskCreditCost(pointsSystemConfig, {
|
||
taskType: "image_to_model",
|
||
}),
|
||
[pointsSystemConfig]
|
||
);
|
||
const worldImageCreditCost = useMemo(
|
||
() => {
|
||
const worldBillingModelId = resolveThreeDModelBillingId(
|
||
selectedWorldModelEntry?.id || selectedWorldModelVersion,
|
||
publicModelAliasConfig.worldModels
|
||
);
|
||
return get3DWorldCreditCost(pointsSystemConfig, worldBillingModelId);
|
||
},
|
||
[
|
||
pointsSystemConfig,
|
||
publicModelAliasConfig.worldModels,
|
||
selectedWorldModelEntry?.id,
|
||
selectedWorldModelVersion,
|
||
]
|
||
);
|
||
const tripoImageToModelCostLabel = formatImageNodeFeatureCostLabel({
|
||
cost: tripoImageToModelCreditCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const worldImageCostLabel = formatImageNodeFeatureCostLabel({
|
||
cost: worldImageCreditCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const tripoImageToModelCostShortLabel = formatImageNodeFeatureCostShortLabel({
|
||
cost: tripoImageToModelCreditCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const worldImageCostShortLabel = formatImageNodeFeatureCostShortLabel({
|
||
cost: worldImageCreditCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const getImageToolCostShortLabel = useCallback(
|
||
(feature: ImageToolCreditFeature) =>
|
||
formatImageNodeFeatureCostShortLabel({
|
||
cost: getImageToolCreditCost(pointsSystemConfig, feature),
|
||
isHighVip,
|
||
pointsEnabled,
|
||
}),
|
||
[isHighVip, pointsEnabled, pointsSystemConfig]
|
||
);
|
||
const removeBackgroundCostShortLabel =
|
||
getImageToolCostShortLabel("remove-background");
|
||
const boxCutoutCostShortLabel = getImageToolCostShortLabel("box-cutout");
|
||
const removeWatermarkCostShortLabel =
|
||
getImageToolCostShortLabel("watermark-remove");
|
||
const extendCostShortLabel = getImageToolCostShortLabel("extend");
|
||
const promptRegenerateCostShortLabel = getImageToolCostShortLabel("repaint");
|
||
const localEditCostShortLabel = promptRegenerateCostShortLabel;
|
||
const angleEditCostShortLabel = getImageToolCostShortLabel("angle-edit");
|
||
const enhanceCostShortLabel = formatImageNodeFeatureCostShortLabel({
|
||
cost: calculateEnhanceProPointsEstimate(pointsSystemConfig, {
|
||
width: Math.max(1, Math.round(node.size.width || 1)),
|
||
height: Math.max(1, Math.round(node.size.height || 1)),
|
||
upscalingActivated: true,
|
||
scaleFactor: 2,
|
||
}).cost,
|
||
isHighVip,
|
||
pointsEnabled:
|
||
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
|
||
});
|
||
const aiWashQuickCostShortLabel =
|
||
pointsEnabled && selectedAiWashQuickModel
|
||
? formatImageNodeFeatureCostShortLabel({
|
||
cost: selectedAiWashQuickModel.unitCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
})
|
||
: null;
|
||
|
||
// 🆕 物品拾取相关状态
|
||
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
|
||
const pickerTargetNodeId = useCanvasStore(
|
||
(state) => state.pickerTargetNodeId
|
||
);
|
||
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
|
||
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
|
||
const addPendingPickedObject = useCanvasStore(
|
||
(state) => state.addPendingPickedObject
|
||
);
|
||
// 当前节点是否处于拾取模式
|
||
const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id;
|
||
|
||
// 当前正在识别的点击位置
|
||
const [currentClick, setCurrentClick] = useState<{
|
||
x: number;
|
||
y: number;
|
||
} | null>(null);
|
||
const displayTitle = getImageNodeTitle(data, node.id);
|
||
const agentExecutionSnapshotId =
|
||
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
||
? data.metadata.agentExecutionSnapshotId.trim()
|
||
: "";
|
||
const detailUiScreenOverlay = useMemo(() => {
|
||
const raw = data.metadata?.detailUiScreenOverlay;
|
||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||
return raw as DetailUiScreenOverlaySnapshot;
|
||
}, [data.metadata]);
|
||
const detailSubjectAnalysis = useMemo(() => {
|
||
return normalizeDetailUiSubjectAnalysis(
|
||
data.metadata?.detailSubjectAnalysis
|
||
);
|
||
}, [data.metadata]);
|
||
const detailUiDslDocumentFromMetadata = useMemo(() => {
|
||
const raw = data.metadata?.detailUiDslDocument;
|
||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||
return raw as DetailUiDslV2Document;
|
||
}, [data.metadata]);
|
||
const detailUiDslDocument = useMemo(() => {
|
||
if (detailUiDslDocumentFromMetadata) return detailUiDslDocumentFromMetadata;
|
||
if (!detailUiScreenOverlay) return null;
|
||
return buildDetailUiDslV2({
|
||
overlay: detailUiScreenOverlay,
|
||
subjectAnalysis: detailSubjectAnalysis,
|
||
});
|
||
}, [
|
||
detailSubjectAnalysis,
|
||
detailUiDslDocumentFromMetadata,
|
||
detailUiScreenOverlay,
|
||
]);
|
||
const shouldRenderDetailOverlay =
|
||
data.metadata?.detailGenerationMode === "agent_layout" &&
|
||
Boolean(detailUiScreenOverlay);
|
||
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
|
||
? agentExecutionSnapshotId.slice(0, 8)
|
||
: "";
|
||
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
|
||
const 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 = resolveOriginalImageUrl(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 localEditPromptInlineOverlays = useMemo(() => {
|
||
if (!localEditMode || !boxCutoutMetrics) {
|
||
return [];
|
||
}
|
||
|
||
return localEditRegionOverlays.map(({ region, style }) => {
|
||
const left = Number(style.left || 0);
|
||
const top = Number(style.top || 0);
|
||
const width = Number(style.width || 0);
|
||
const height = Number(style.height || 0);
|
||
const imageBottom = boxCutoutMetrics.offsetY + boxCutoutMetrics.renderedHeight;
|
||
const referenceCount = region.referenceImages?.length || 0;
|
||
const promptStackHeight = referenceCount > 0 ? 82 : 42;
|
||
const placeAbove = top + height + promptStackHeight + 14 > imageBottom;
|
||
const promptTop = placeAbove
|
||
? top - promptStackHeight - 8
|
||
: top + height + 8;
|
||
const promptWidth = Math.min(Math.max(width, 236), 360);
|
||
const isEditing =
|
||
editingLocalEditRegionId === region.id || !region.prompt.trim();
|
||
|
||
return {
|
||
region,
|
||
isEditing,
|
||
style: {
|
||
left,
|
||
top: Math.max(4, promptTop),
|
||
width: promptWidth,
|
||
zIndex: isEditing ? 190 : 170,
|
||
} satisfies React.CSSProperties,
|
||
};
|
||
});
|
||
}, [
|
||
boxCutoutMetrics,
|
||
editingLocalEditRegionId,
|
||
localEditMode,
|
||
localEditRegionOverlays,
|
||
]);
|
||
const localEditPromptViewportOverlays = useMemo(
|
||
() =>
|
||
localEditPromptInlineOverlays.map(({ region, isEditing, style }) => ({
|
||
region,
|
||
isEditing,
|
||
style: {
|
||
...style,
|
||
left: Number(style.left || 0) * nodeContentScale,
|
||
top: Number(style.top || 0) * nodeContentScale,
|
||
width: Number(style.width || 0) * nodeContentScale,
|
||
} satisfies React.CSSProperties,
|
||
})),
|
||
[localEditPromptInlineOverlays, nodeContentScale]
|
||
);
|
||
useEffect(() => {
|
||
if (
|
||
!localEditMode ||
|
||
!data.src ||
|
||
localEditPromptViewportOverlays.length === 0 ||
|
||
typeof window === "undefined"
|
||
) {
|
||
setLocalEditPromptPortalFrame(null);
|
||
return;
|
||
}
|
||
|
||
let animationFrame = 0;
|
||
const updatePortalFrame = () => {
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
if (!rect) {
|
||
return;
|
||
}
|
||
|
||
const nextFrame = {
|
||
left: rect.left,
|
||
top: rect.top,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
};
|
||
setLocalEditPromptPortalFrame((currentFrame) => {
|
||
if (
|
||
currentFrame &&
|
||
Math.abs(currentFrame.left - nextFrame.left) < 0.5 &&
|
||
Math.abs(currentFrame.top - nextFrame.top) < 0.5 &&
|
||
Math.abs(currentFrame.width - nextFrame.width) < 0.5 &&
|
||
Math.abs(currentFrame.height - nextFrame.height) < 0.5
|
||
) {
|
||
return currentFrame;
|
||
}
|
||
return nextFrame;
|
||
});
|
||
};
|
||
const schedulePortalFrameUpdate = () => {
|
||
window.cancelAnimationFrame(animationFrame);
|
||
animationFrame = window.requestAnimationFrame(updatePortalFrame);
|
||
};
|
||
|
||
updatePortalFrame();
|
||
const intervalId = window.setInterval(updatePortalFrame, 250);
|
||
window.addEventListener("resize", schedulePortalFrameUpdate);
|
||
window.addEventListener("scroll", schedulePortalFrameUpdate, true);
|
||
return () => {
|
||
window.cancelAnimationFrame(animationFrame);
|
||
window.clearInterval(intervalId);
|
||
window.removeEventListener("resize", schedulePortalFrameUpdate);
|
||
window.removeEventListener("scroll", schedulePortalFrameUpdate, true);
|
||
};
|
||
}, [data.src, localEditMode, localEditPromptViewportOverlays.length]);
|
||
const currentPickerClickStyle = useMemo(() => {
|
||
if (!boxCutoutMetrics || !currentClick) return null;
|
||
|
||
return {
|
||
left:
|
||
boxCutoutMetrics.offsetX +
|
||
currentClick.x * boxCutoutMetrics.renderedWidth,
|
||
top:
|
||
boxCutoutMetrics.offsetY +
|
||
currentClick.y * boxCutoutMetrics.renderedHeight,
|
||
};
|
||
}, [boxCutoutMetrics, currentClick]);
|
||
const cropRectStyle = useMemo(() => {
|
||
if (!boxCutoutMetrics || !cropRect) return null;
|
||
|
||
return {
|
||
left: boxCutoutMetrics.offsetX + cropRect.x * boxCutoutMetrics.renderedWidth,
|
||
top: boxCutoutMetrics.offsetY + cropRect.y * boxCutoutMetrics.renderedHeight,
|
||
width: cropRect.width * boxCutoutMetrics.renderedWidth,
|
||
height: cropRect.height * boxCutoutMetrics.renderedHeight,
|
||
};
|
||
}, [boxCutoutMetrics, cropRect]);
|
||
const cropPixelSize = useMemo(() => {
|
||
if (!cropRect || !cropImageMetrics) {
|
||
return { width: 0, height: 0 };
|
||
}
|
||
return {
|
||
width: Math.max(
|
||
0,
|
||
Math.round(cropRect.width * cropImageMetrics.naturalWidth)
|
||
),
|
||
height: Math.max(
|
||
0,
|
||
Math.round(cropRect.height * cropImageMetrics.naturalHeight)
|
||
),
|
||
};
|
||
}, [cropImageMetrics, cropRect]);
|
||
const currentCropAspectRatio =
|
||
cropRect && cropRect.height > 0 ? cropRect.width / cropRect.height : null;
|
||
const selectedCropPreset = getCropPresetById(cropPresetId);
|
||
const effectiveCropAspectRatio =
|
||
cropLockAspectRatio
|
||
? selectedCropPreset?.ratio || currentCropAspectRatio
|
||
: null;
|
||
|
||
const resetBoxCutoutState = useCallback(() => {
|
||
setBoxCutoutMode(false);
|
||
setIsBoxCutoutDrawing(false);
|
||
setBoxCutoutSelection(null);
|
||
setBoxCutoutDragStart(null);
|
||
setBoxCutoutDragEnd(null);
|
||
}, []);
|
||
|
||
const resetLocalEditState = useCallback(() => {
|
||
setLocalEditMode(false);
|
||
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)
|
||
);
|
||
const nextDrafts = createLocalEditPromptDraftMap(
|
||
nextRegions,
|
||
localEditPromptDraftRefs.current
|
||
);
|
||
localEditRegionsRef.current = nextRegions;
|
||
setLocalEditRegions(nextRegions);
|
||
localEditPromptDraftRefs.current = nextDrafts;
|
||
setLocalEditPromptDrafts(nextDrafts);
|
||
|
||
const nextMetadata =
|
||
data.metadata && typeof data.metadata === "object"
|
||
? { ...(data.metadata as Record<string, unknown>) }
|
||
: {};
|
||
|
||
if (nextRegions.length > 0) {
|
||
nextMetadata[LOCAL_EDIT_METADATA_KEY] = nextRegions;
|
||
} else {
|
||
delete nextMetadata[LOCAL_EDIT_METADATA_KEY];
|
||
}
|
||
|
||
updateNode(node.id, {
|
||
data: {
|
||
...data,
|
||
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
|
||
},
|
||
});
|
||
},
|
||
[data, node.id, updateNode]
|
||
);
|
||
|
||
const updateLocalEditPromptDraft = useCallback(
|
||
(regionId: string, prompt: string) => {
|
||
localEditPromptDraftRefs.current[regionId] = prompt;
|
||
setLocalEditPromptDrafts((current) =>
|
||
current[regionId] === prompt
|
||
? current
|
||
: {
|
||
...current,
|
||
[regionId]: prompt,
|
||
}
|
||
);
|
||
},
|
||
[]
|
||
);
|
||
const updateLocalEditPromptDraftRef = useCallback(
|
||
(regionId: string, prompt: string) => {
|
||
localEditPromptDraftRefs.current[regionId] = prompt;
|
||
},
|
||
[]
|
||
);
|
||
|
||
const restoreLocalEditPromptSelection = useCallback(
|
||
(regionId: string, position: number) => {
|
||
if (typeof window === "undefined") return;
|
||
window.requestAnimationFrame(() => {
|
||
const input = localEditPromptInputRefs.current[regionId];
|
||
if (!input) return;
|
||
const safePosition = clampNumber(position, 0, input.value.length);
|
||
input.setSelectionRange(safePosition, safePosition);
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
const applyLocalEditPromptKeyEdit = useCallback(
|
||
(
|
||
regionId: string,
|
||
input: HTMLInputElement,
|
||
event: React.KeyboardEvent<HTMLInputElement>
|
||
) => {
|
||
if (
|
||
event.nativeEvent.isComposing ||
|
||
event.key === "Process" ||
|
||
event.metaKey ||
|
||
event.ctrlKey ||
|
||
event.altKey
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
const currentValue = localEditPromptDraftRefs.current[regionId] ?? input.value;
|
||
const selectionStart = input.selectionStart ?? currentValue.length;
|
||
const selectionEnd = input.selectionEnd ?? selectionStart;
|
||
const start = clampNumber(selectionStart, 0, currentValue.length);
|
||
const end = clampNumber(selectionEnd, start, currentValue.length);
|
||
let nextValue: string | null = null;
|
||
let nextCursor = start;
|
||
|
||
if (event.key.length === 1) {
|
||
nextValue = `${currentValue.slice(0, start)}${event.key}${currentValue.slice(end)}`;
|
||
nextCursor = start + event.key.length;
|
||
} else if (event.key === "Backspace") {
|
||
if (start !== end) {
|
||
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(end)}`;
|
||
nextCursor = start;
|
||
} else if (start > 0) {
|
||
nextValue = `${currentValue.slice(0, start - 1)}${currentValue.slice(end)}`;
|
||
nextCursor = start - 1;
|
||
}
|
||
} else if (event.key === "Delete") {
|
||
if (start !== end) {
|
||
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(end)}`;
|
||
} else if (start < currentValue.length) {
|
||
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(start + 1)}`;
|
||
}
|
||
nextCursor = start;
|
||
}
|
||
|
||
if (nextValue === null) {
|
||
return false;
|
||
}
|
||
|
||
event.preventDefault();
|
||
input.value = nextValue;
|
||
updateLocalEditPromptDraftRef(regionId, nextValue);
|
||
restoreLocalEditPromptSelection(regionId, nextCursor);
|
||
return true;
|
||
},
|
||
[restoreLocalEditPromptSelection, updateLocalEditPromptDraftRef]
|
||
);
|
||
|
||
const finishLocalEditPromptComposition = useCallback(
|
||
(regionId: string, input: HTMLInputElement, compositionText: string) => {
|
||
const snapshot = localEditPromptCompositionStartRefs.current[regionId];
|
||
delete localEditPromptCompositionStartRefs.current[regionId];
|
||
if (!snapshot) {
|
||
updateLocalEditPromptDraftRef(regionId, input.value);
|
||
return;
|
||
}
|
||
|
||
if (input.value !== snapshot.value) {
|
||
updateLocalEditPromptDraftRef(regionId, input.value);
|
||
return;
|
||
}
|
||
|
||
if (!compositionText) {
|
||
return;
|
||
}
|
||
|
||
const nextValue = `${snapshot.value.slice(0, snapshot.start)}${compositionText}${snapshot.value.slice(snapshot.end)}`;
|
||
input.value = nextValue;
|
||
updateLocalEditPromptDraftRef(regionId, nextValue);
|
||
restoreLocalEditPromptSelection(
|
||
regionId,
|
||
snapshot.start + compositionText.length
|
||
);
|
||
},
|
||
[restoreLocalEditPromptSelection, updateLocalEditPromptDraftRef]
|
||
);
|
||
|
||
const refreshBoxCutoutMetrics = useCallback(() => {
|
||
const imageElement = displayImageRef.current;
|
||
if (!imageElement) {
|
||
setBoxCutoutMetrics(null);
|
||
return;
|
||
}
|
||
|
||
const naturalWidth =
|
||
imageElement.naturalWidth ||
|
||
metadataResolution?.width ||
|
||
loadedResolution?.width ||
|
||
0;
|
||
const naturalHeight =
|
||
imageElement.naturalHeight ||
|
||
metadataResolution?.height ||
|
||
loadedResolution?.height ||
|
||
0;
|
||
const boxWidth = imageElement.clientWidth;
|
||
const boxHeight = imageElement.clientHeight;
|
||
|
||
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
|
||
setBoxCutoutMetrics(null);
|
||
return;
|
||
}
|
||
|
||
const displayScale = Math.min(
|
||
boxWidth / naturalWidth,
|
||
boxHeight / naturalHeight
|
||
);
|
||
const renderedWidth = naturalWidth * displayScale;
|
||
const renderedHeight = naturalHeight * displayScale;
|
||
|
||
setBoxCutoutMetrics({
|
||
offsetX: (boxWidth - renderedWidth) / 2,
|
||
offsetY: (boxHeight - renderedHeight) / 2,
|
||
renderedWidth,
|
||
renderedHeight,
|
||
naturalWidth,
|
||
naturalHeight,
|
||
});
|
||
}, [loadedResolution, metadataResolution]);
|
||
|
||
const getBoxCutoutPointerPoint = useCallback(
|
||
(clientX: number, clientY: number) => {
|
||
const imageElement = displayImageRef.current;
|
||
if (!imageElement) return null;
|
||
|
||
const currentMetrics = (() => {
|
||
if (
|
||
boxCutoutMetrics &&
|
||
boxCutoutMetrics.renderedWidth > 0 &&
|
||
boxCutoutMetrics.renderedHeight > 0
|
||
) {
|
||
return boxCutoutMetrics;
|
||
}
|
||
|
||
const naturalWidth =
|
||
imageElement.naturalWidth ||
|
||
metadataResolution?.width ||
|
||
loadedResolution?.width ||
|
||
0;
|
||
const naturalHeight =
|
||
imageElement.naturalHeight ||
|
||
metadataResolution?.height ||
|
||
loadedResolution?.height ||
|
||
0;
|
||
const boxWidth = imageElement.clientWidth;
|
||
const boxHeight = imageElement.clientHeight;
|
||
|
||
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
|
||
return null;
|
||
}
|
||
|
||
const displayScale = Math.min(
|
||
boxWidth / naturalWidth,
|
||
boxHeight / naturalHeight
|
||
);
|
||
return {
|
||
offsetX: (boxWidth - naturalWidth * displayScale) / 2,
|
||
offsetY: (boxHeight - naturalHeight * displayScale) / 2,
|
||
renderedWidth: naturalWidth * displayScale,
|
||
renderedHeight: naturalHeight * displayScale,
|
||
naturalWidth,
|
||
naturalHeight,
|
||
} satisfies ImageDisplayMetrics;
|
||
})();
|
||
|
||
if (!currentMetrics) return null;
|
||
|
||
const rect = imageElement.getBoundingClientRect();
|
||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||
|
||
// The image node content may be counter-scaled with CSS transforms.
|
||
// getBoundingClientRect() returns the transformed size, while
|
||
// object-contain offsets are calculated in the element's local box.
|
||
// Convert the pointer back into that local coordinate space first.
|
||
const localX =
|
||
((clientX - rect.left) / rect.width) * imageElement.clientWidth;
|
||
const localY =
|
||
((clientY - rect.top) / rect.height) * imageElement.clientHeight;
|
||
const rawX = localX - currentMetrics.offsetX;
|
||
const rawY = localY - currentMetrics.offsetY;
|
||
const clampedX = clampNumber(rawX, 0, currentMetrics.renderedWidth);
|
||
const clampedY = clampNumber(rawY, 0, currentMetrics.renderedHeight);
|
||
|
||
return {
|
||
point: {
|
||
x:
|
||
currentMetrics.renderedWidth > 0
|
||
? clampedX / currentMetrics.renderedWidth
|
||
: 0,
|
||
y:
|
||
currentMetrics.renderedHeight > 0
|
||
? clampedY / currentMetrics.renderedHeight
|
||
: 0,
|
||
} satisfies BoxCutoutPoint,
|
||
insideImage:
|
||
rawX >= 0 &&
|
||
rawX <= currentMetrics.renderedWidth &&
|
||
rawY >= 0 &&
|
||
rawY <= currentMetrics.renderedHeight,
|
||
metrics: currentMetrics,
|
||
};
|
||
},
|
||
[boxCutoutMetrics, loadedResolution, metadataResolution]
|
||
);
|
||
|
||
// 退出拾取模式时清空状态
|
||
useEffect(() => {
|
||
if (!isThisNodePicking) {
|
||
setCurrentClick(null);
|
||
}
|
||
}, [isThisNodePicking]);
|
||
|
||
useEffect(() => {
|
||
if (!textEditPanelOpen) {
|
||
setTextEditItems(normalizeTextEditItems(data.textEditItems));
|
||
setDeletedTextItems([]);
|
||
setTextEditError(null);
|
||
}
|
||
}, [data.textEditItems, textEditPanelOpen]);
|
||
|
||
useEffect(() => {
|
||
if (localEditMode) return;
|
||
const nextRegions = getLocalEditRegionsFromMetadata(data.metadata);
|
||
const nextDrafts = createLocalEditPromptDraftMap(nextRegions);
|
||
localEditPromptDraftRefs.current = nextDrafts;
|
||
setLocalEditPromptDrafts(nextDrafts);
|
||
setLocalEditRegions(nextRegions);
|
||
}, [data.metadata, data.src, localEditMode]);
|
||
|
||
useEffect(() => {
|
||
if (!isSelected) {
|
||
setCropPanelOpen(false);
|
||
setCropRect(null);
|
||
setCropInteraction(null);
|
||
setImageAdjustPanelOpen(false);
|
||
setFlipRotatePanelOpen(false);
|
||
setAnglePanelOpen(false);
|
||
setTextEditPanelOpen(false);
|
||
setIsEditingTitle(false);
|
||
setPromptDialogOpen(false);
|
||
resetBoxCutoutState();
|
||
// 局部编辑有独立的“退出”按钮,避免用户点到浮层或画布边缘时丢失框选与底部操作条。
|
||
if (!localEditMode) {
|
||
resetLocalEditState();
|
||
}
|
||
}
|
||
}, [isSelected, localEditMode, resetBoxCutoutState, resetLocalEditState]);
|
||
|
||
useEffect(() => {
|
||
if (anglePanelOpen) return;
|
||
anglePreviewDragRef.current = null;
|
||
setIsAnglePreviewDragging(false);
|
||
}, [anglePanelOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!hasCompareSource || isThisNodePicking) {
|
||
setIsCompareMode(false);
|
||
}
|
||
}, [hasCompareSource, isThisNodePicking]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
(isThisNodePicking ||
|
||
isCompareMode ||
|
||
sceneFusionDialogOpen ||
|
||
promptDialogOpen ||
|
||
notePanelOpen) &&
|
||
(boxCutoutMode || cropPanelOpen || localEditMode)
|
||
) {
|
||
resetBoxCutoutState();
|
||
resetLocalEditState();
|
||
setCropPanelOpen(false);
|
||
setCropInteraction(null);
|
||
}
|
||
}, [
|
||
boxCutoutMode,
|
||
cropPanelOpen,
|
||
isCompareMode,
|
||
isThisNodePicking,
|
||
localEditMode,
|
||
notePanelOpen,
|
||
promptDialogOpen,
|
||
resetBoxCutoutState,
|
||
resetLocalEditState,
|
||
sceneFusionDialogOpen,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!boxCutoutMode || typeof window === "undefined") return;
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
resetBoxCutoutState();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeyDown);
|
||
};
|
||
}, [boxCutoutMode, resetBoxCutoutState]);
|
||
|
||
useEffect(() => {
|
||
if (!localEditMode || typeof window === "undefined") return;
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (isLocalEditControlTarget(event.target)) {
|
||
return;
|
||
}
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
resetLocalEditState();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeyDown);
|
||
};
|
||
}, [localEditMode, resetLocalEditState]);
|
||
|
||
useEffect(() => {
|
||
if (!cropPanelOpen || typeof window === "undefined") return;
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setCropPanelOpen(false);
|
||
setCropInteraction(null);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeyDown);
|
||
};
|
||
}, [cropPanelOpen]);
|
||
|
||
useEffect(() => {
|
||
setLoadedResolution(null);
|
||
}, [data.src]);
|
||
|
||
useEffect(() => {
|
||
setHasEnteredViewport(false);
|
||
}, [data.src]);
|
||
|
||
useEffect(() => {
|
||
if (!data.src || hasEnteredViewport || typeof window === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const element = containerRef.current;
|
||
if (!element || typeof IntersectionObserver === "undefined") {
|
||
setHasEnteredViewport(true);
|
||
return;
|
||
}
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) {
|
||
setHasEnteredViewport(true);
|
||
observer.disconnect();
|
||
}
|
||
},
|
||
{
|
||
root: null,
|
||
// 轻微提前加载,保证拖动画布时体验顺滑,但不请求远离视口的图片。
|
||
rootMargin: "320px",
|
||
threshold: 0.01,
|
||
}
|
||
);
|
||
|
||
observer.observe(element);
|
||
return () => observer.disconnect();
|
||
}, [data.src, hasEnteredViewport]);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
|
||
let rafId: number | null = null;
|
||
const scheduleRefresh = () => {
|
||
if (rafId !== null) {
|
||
window.cancelAnimationFrame(rafId);
|
||
}
|
||
rafId = window.requestAnimationFrame(() => {
|
||
refreshBoxCutoutMetrics();
|
||
});
|
||
};
|
||
|
||
scheduleRefresh();
|
||
window.addEventListener("resize", scheduleRefresh);
|
||
|
||
const resizeObserver =
|
||
typeof ResizeObserver !== "undefined"
|
||
? new ResizeObserver(scheduleRefresh)
|
||
: null;
|
||
|
||
if (containerRef.current) {
|
||
resizeObserver?.observe(containerRef.current);
|
||
}
|
||
if (displayImageRef.current) {
|
||
resizeObserver?.observe(displayImageRef.current);
|
||
}
|
||
|
||
return () => {
|
||
if (rafId !== null) {
|
||
window.cancelAnimationFrame(rafId);
|
||
}
|
||
window.removeEventListener("resize", scheduleRefresh);
|
||
resizeObserver?.disconnect();
|
||
};
|
||
}, [data.src, refreshBoxCutoutMetrics]);
|
||
|
||
const updateCompareSplitByClientX = useCallback((clientX: number) => {
|
||
const frame = compareFrameRef.current;
|
||
if (!frame) return;
|
||
|
||
const rect = frame.getBoundingClientRect();
|
||
if (rect.width <= 0) return;
|
||
|
||
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
|
||
setCompareSplitPercent(Math.max(0, Math.min(100, rawPercent)));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isCompareMode) return;
|
||
|
||
const handlePointerDownOutside = (event: PointerEvent) => {
|
||
const target = event.target as Node | null;
|
||
if (!target) return;
|
||
const container = containerRef.current;
|
||
if (container && container.contains(target)) return;
|
||
setIsCompareMode(false);
|
||
};
|
||
|
||
document.addEventListener("pointerdown", handlePointerDownOutside, true);
|
||
return () => {
|
||
document.removeEventListener(
|
||
"pointerdown",
|
||
handlePointerDownOutside,
|
||
true
|
||
);
|
||
};
|
||
}, [isCompareMode]);
|
||
|
||
useEffect(() => {
|
||
if (!isEditingTitle) {
|
||
setDraftTitle(getImageNodeTitle(data, node.id));
|
||
}
|
||
}, [data, isEditingTitle, node.id]);
|
||
|
||
useEffect(() => {
|
||
if (promptDialogOpen) {
|
||
setRegeneratePrompt(resolveRegeneratePromptSeed(data));
|
||
}
|
||
}, [data, promptDialogOpen]);
|
||
|
||
useEffect(() => {
|
||
if (notePanelOpen) {
|
||
setDraftNote(data.note || "");
|
||
}
|
||
}, [data.note, notePanelOpen]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
typeof window === "undefined" ||
|
||
!isSelected ||
|
||
isMultiSelected ||
|
||
!data.src ||
|
||
isEditingTitle ||
|
||
sceneFusionDialogOpen ||
|
||
promptDialogOpen ||
|
||
notePanelOpen ||
|
||
boxCutoutMode ||
|
||
localEditMode
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const toolbarEl = toolbarRef.current;
|
||
if (!toolbarEl) return;
|
||
|
||
let rafId: number | null = null;
|
||
const measure = () => {
|
||
const next = {
|
||
width: toolbarEl.offsetWidth,
|
||
height: toolbarEl.offsetHeight,
|
||
};
|
||
setToolbarSize((prev) =>
|
||
Math.abs(prev.width - next.width) < 0.5 &&
|
||
Math.abs(prev.height - next.height) < 0.5
|
||
? prev
|
||
: next
|
||
);
|
||
};
|
||
|
||
const scheduleMeasure = () => {
|
||
if (rafId !== null) window.cancelAnimationFrame(rafId);
|
||
rafId = window.requestAnimationFrame(measure);
|
||
};
|
||
|
||
scheduleMeasure();
|
||
window.addEventListener("resize", scheduleMeasure);
|
||
|
||
const resizeObserver =
|
||
typeof ResizeObserver !== "undefined"
|
||
? new ResizeObserver(scheduleMeasure)
|
||
: null;
|
||
resizeObserver?.observe(toolbarEl);
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", scheduleMeasure);
|
||
resizeObserver?.disconnect();
|
||
if (rafId !== null) window.cancelAnimationFrame(rafId);
|
||
};
|
||
}, [
|
||
boxCutoutMode,
|
||
data.src,
|
||
isEditingTitle,
|
||
isMultiSelected,
|
||
isSelected,
|
||
localEditMode,
|
||
notePanelOpen,
|
||
promptDialogOpen,
|
||
sceneFusionDialogOpen,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
typeof window === "undefined" ||
|
||
!isSelected ||
|
||
isMultiSelected ||
|
||
!data.src ||
|
||
(!boxCutoutMode && !localEditMode)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const toolbarEl = boxCutoutToolbarRef.current;
|
||
if (!toolbarEl) return;
|
||
|
||
let rafId: number | null = null;
|
||
const measure = () => {
|
||
const next = {
|
||
width: toolbarEl.offsetWidth,
|
||
height: toolbarEl.offsetHeight,
|
||
};
|
||
setBoxCutoutToolbarSize((prev) =>
|
||
Math.abs(prev.width - next.width) < 0.5 &&
|
||
Math.abs(prev.height - next.height) < 0.5
|
||
? prev
|
||
: next
|
||
);
|
||
};
|
||
|
||
const scheduleMeasure = () => {
|
||
if (rafId !== null) window.cancelAnimationFrame(rafId);
|
||
rafId = window.requestAnimationFrame(measure);
|
||
};
|
||
|
||
scheduleMeasure();
|
||
window.addEventListener("resize", scheduleMeasure);
|
||
|
||
const resizeObserver =
|
||
typeof ResizeObserver !== "undefined"
|
||
? new ResizeObserver(scheduleMeasure)
|
||
: null;
|
||
resizeObserver?.observe(toolbarEl);
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", scheduleMeasure);
|
||
resizeObserver?.disconnect();
|
||
if (rafId !== null) window.cancelAnimationFrame(rafId);
|
||
};
|
||
}, [boxCutoutMode, data.src, isMultiSelected, isSelected, localEditMode]);
|
||
|
||
// 🆕 处理图片点击标注
|
||
const handlePickerClick = useCallback(
|
||
async (e: React.MouseEvent<HTMLImageElement>) => {
|
||
if (!isThisNodePicking || isPickerLoading || !data.src) return;
|
||
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
const pointerResult = getBoxCutoutPointerPoint(e.clientX, e.clientY);
|
||
if (!pointerResult?.insideImage) {
|
||
toast.info("请点击图片内容区域");
|
||
return;
|
||
}
|
||
|
||
const clickX = pointerResult.point.x;
|
||
const clickY = pointerResult.point.y;
|
||
|
||
setCurrentClick({ x: clickX, y: clickY });
|
||
setPickerLoading(true);
|
||
|
||
try {
|
||
// 在图片上绘制红色标记点
|
||
const markedImageBase64 = await drawMarkerOnImage(
|
||
data.src,
|
||
clickX,
|
||
clickY
|
||
);
|
||
|
||
const response = await fetch("/api/pick-object", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
imageData: markedImageBase64,
|
||
clickX,
|
||
clickY,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) throw new Error("识别失败");
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.object) {
|
||
const picked: PickedObject = {
|
||
imageUrl: data.src,
|
||
imageNodeId: node.id,
|
||
clickX,
|
||
clickY,
|
||
objectName: result.object.name,
|
||
objectPosition: result.object.position,
|
||
confidence: result.object.confidence,
|
||
boundingBox: result.object.boundingBox,
|
||
};
|
||
|
||
addPendingPickedObject(picked);
|
||
|
||
// 标注成功后自动关闭拾取模式,直接生成标签
|
||
setPickerMode(false);
|
||
setCurrentClick(null);
|
||
toast.success(`已添加: ${result.object.name}`);
|
||
} else {
|
||
toast.error(result.error || "未能识别物品");
|
||
setCurrentClick(null);
|
||
}
|
||
} catch (error) {
|
||
console.error("物品拾取失败:", error);
|
||
toast.error("识别失败,请重试");
|
||
setCurrentClick(null);
|
||
} finally {
|
||
setPickerLoading(false);
|
||
}
|
||
},
|
||
[
|
||
isThisNodePicking,
|
||
isPickerLoading,
|
||
data.src,
|
||
getBoxCutoutPointerPoint,
|
||
node.id,
|
||
setPickerLoading,
|
||
addPendingPickedObject,
|
||
setPickerMode,
|
||
]
|
||
);
|
||
|
||
// 🆕 取消标注
|
||
const handleCancelPicking = useCallback(() => {
|
||
setPickerMode(false);
|
||
}, [setPickerMode]);
|
||
|
||
const handleStartPicking = useCallback(
|
||
(event?: React.MouseEvent) => {
|
||
event?.stopPropagation();
|
||
if (!data.src) {
|
||
toast.error("请先上传图片");
|
||
return;
|
||
}
|
||
if (isProcessing) {
|
||
toast.info("正在处理中,请稍候...");
|
||
return;
|
||
}
|
||
|
||
resetLocalEditState();
|
||
setPickerMode(true, node.id);
|
||
toast.info("点击图片中的物品进行拾取");
|
||
},
|
||
[data.src, isProcessing, node.id, resetLocalEditState, setPickerMode]
|
||
);
|
||
|
||
const handleEnterBoxCutoutMode = useCallback(
|
||
(event?: React.MouseEvent | React.PointerEvent) => {
|
||
event?.stopPropagation();
|
||
if (!data.src) {
|
||
toast.error("请先上传图片");
|
||
return;
|
||
}
|
||
if (isProcessing) {
|
||
toast.info("正在处理中,请稍候...");
|
||
return;
|
||
}
|
||
|
||
if (isThisNodePicking) {
|
||
setPickerMode(false);
|
||
}
|
||
setIsCompareMode(false);
|
||
resetLocalEditState();
|
||
setBoxCutoutSelection(null);
|
||
setBoxCutoutDragStart(null);
|
||
setBoxCutoutDragEnd(null);
|
||
setIsBoxCutoutDrawing(false);
|
||
setBoxCutoutMode(true);
|
||
window.requestAnimationFrame(() => {
|
||
refreshBoxCutoutMetrics();
|
||
});
|
||
},
|
||
[
|
||
data.src,
|
||
isProcessing,
|
||
isThisNodePicking,
|
||
refreshBoxCutoutMetrics,
|
||
resetLocalEditState,
|
||
setPickerMode,
|
||
]
|
||
);
|
||
|
||
const handleBoxCutoutPointerDown = useCallback(
|
||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!boxCutoutMode || isProcessing) return;
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const pointerResult = getBoxCutoutPointerPoint(
|
||
event.clientX,
|
||
event.clientY
|
||
);
|
||
if (!pointerResult?.insideImage) {
|
||
return;
|
||
}
|
||
|
||
setBoxCutoutSelection(null);
|
||
setBoxCutoutDragStart(pointerResult.point);
|
||
setBoxCutoutDragEnd(pointerResult.point);
|
||
setIsBoxCutoutDrawing(true);
|
||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||
},
|
||
[boxCutoutMode, getBoxCutoutPointerPoint, isProcessing]
|
||
);
|
||
|
||
const handleBoxCutoutPointerMove = useCallback(
|
||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!boxCutoutMode || !isBoxCutoutDrawing) return;
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const pointerResult = getBoxCutoutPointerPoint(
|
||
event.clientX,
|
||
event.clientY
|
||
);
|
||
if (!pointerResult) return;
|
||
|
||
setBoxCutoutDragEnd(pointerResult.point);
|
||
},
|
||
[boxCutoutMode, getBoxCutoutPointerPoint, isBoxCutoutDrawing]
|
||
);
|
||
|
||
const finalizeBoxCutoutSelection = useCallback(
|
||
(clientX: number, clientY: number) => {
|
||
if (!boxCutoutMode || !boxCutoutDragStart) {
|
||
setIsBoxCutoutDrawing(false);
|
||
return;
|
||
}
|
||
|
||
const pointerResult = getBoxCutoutPointerPoint(clientX, clientY);
|
||
if (!pointerResult) {
|
||
setIsBoxCutoutDrawing(false);
|
||
setBoxCutoutDragStart(null);
|
||
setBoxCutoutDragEnd(null);
|
||
return;
|
||
}
|
||
|
||
const nextRect = createNormalizedRect(
|
||
boxCutoutDragStart,
|
||
pointerResult.point
|
||
);
|
||
const nextWidthPx = nextRect.width * pointerResult.metrics.naturalWidth;
|
||
const nextHeightPx =
|
||
nextRect.height * pointerResult.metrics.naturalHeight;
|
||
|
||
if (
|
||
nextWidthPx >= BOX_CUTOUT_MIN_EDGE_PX &&
|
||
nextHeightPx >= BOX_CUTOUT_MIN_EDGE_PX
|
||
) {
|
||
setBoxCutoutSelection(nextRect);
|
||
} else {
|
||
setBoxCutoutSelection(null);
|
||
}
|
||
|
||
setBoxCutoutDragStart(null);
|
||
setBoxCutoutDragEnd(null);
|
||
setIsBoxCutoutDrawing(false);
|
||
},
|
||
[boxCutoutDragStart, boxCutoutMode, getBoxCutoutPointerPoint]
|
||
);
|
||
|
||
const handleBoxCutoutPointerUp = useCallback(
|
||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!boxCutoutMode) return;
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
finalizeBoxCutoutSelection(event.clientX, event.clientY);
|
||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||
},
|
||
[boxCutoutMode, finalizeBoxCutoutSelection]
|
||
);
|
||
|
||
const handleBoxCutoutPointerCancel = useCallback(
|
||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!boxCutoutMode) return;
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setIsBoxCutoutDrawing(false);
|
||
setBoxCutoutDragStart(null);
|
||
setBoxCutoutDragEnd(null);
|
||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||
},
|
||
[boxCutoutMode]
|
||
);
|
||
|
||
const handleEnterLocalEditMode = useCallback(
|
||
(event?: React.MouseEvent | React.PointerEvent) => {
|
||
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) => {
|
||
updateLocalEditPromptDraft(regionId, prompt);
|
||
commitLocalEditRegions(
|
||
localEditRegionsRef.current.map((region) =>
|
||
region.id === regionId ? { ...region, prompt } : region
|
||
)
|
||
);
|
||
},
|
||
[commitLocalEditRegions, updateLocalEditPromptDraft]
|
||
);
|
||
|
||
const deleteLocalEditRegion = useCallback(
|
||
(regionId: string) => {
|
||
delete localEditPromptDraftRefs.current[regionId];
|
||
delete localEditPromptCompositionStartRefs.current[regionId];
|
||
setLocalEditPromptDrafts((current) => {
|
||
if (!(regionId in current)) return current;
|
||
const next = { ...current };
|
||
delete next[regionId];
|
||
return next;
|
||
});
|
||
commitLocalEditRegions(
|
||
localEditRegionsRef.current.filter((region) => region.id !== regionId)
|
||
);
|
||
if (editingLocalEditRegionId === regionId) {
|
||
setEditingLocalEditRegionId(null);
|
||
}
|
||
},
|
||
[commitLocalEditRegions, editingLocalEditRegionId]
|
||
);
|
||
|
||
const handleLocalEditReferenceUpload = useCallback(
|
||
async (regionId: string, fileList: FileList | null) => {
|
||
const files = Array.from(fileList || []).filter((file) =>
|
||
file.type.startsWith("image/")
|
||
);
|
||
if (files.length === 0) return;
|
||
|
||
const currentRegion = localEditRegionsRef.current.find(
|
||
(region) => region.id === regionId
|
||
);
|
||
if (!currentRegion) return;
|
||
|
||
const currentReferences = currentRegion.referenceImages || [];
|
||
const remainingSlots =
|
||
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION - currentReferences.length;
|
||
if (remainingSlots <= 0) {
|
||
toast.info(
|
||
`每个局部编辑区域最多添加 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
|
||
);
|
||
return;
|
||
}
|
||
|
||
const selectedFiles = files.slice(0, remainingSlots);
|
||
if (files.length > selectedFiles.length) {
|
||
toast.info(
|
||
`已自动保留前 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
|
||
);
|
||
}
|
||
|
||
setUploadingLocalEditReferenceRegionId(regionId);
|
||
try {
|
||
const uploadedReferences = await Promise.all(
|
||
selectedFiles.map(async (file) => {
|
||
const uploaded = await uploadChatAttachmentBlob(file, file.name);
|
||
return {
|
||
id: createLocalEditReferenceImageId(),
|
||
url: uploaded.url,
|
||
name: file.name,
|
||
source: "upload" as const,
|
||
createdAt: Date.now(),
|
||
} satisfies LocalEditReferenceImage;
|
||
})
|
||
);
|
||
|
||
commitLocalEditRegions(
|
||
localEditRegionsRef.current.map((region) =>
|
||
region.id === regionId
|
||
? {
|
||
...region,
|
||
referenceImages: [
|
||
...(region.referenceImages || []),
|
||
...uploadedReferences,
|
||
].slice(0, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION),
|
||
}
|
||
: region
|
||
)
|
||
);
|
||
toast.success(
|
||
uploadedReferences.length > 1
|
||
? `已添加 ${uploadedReferences.length} 张区域参考图`
|
||
: "已添加区域参考图"
|
||
);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "参考图上传失败");
|
||
} finally {
|
||
setUploadingLocalEditReferenceRegionId(null);
|
||
}
|
||
},
|
||
[commitLocalEditRegions]
|
||
);
|
||
|
||
const removeLocalEditReferenceImage = useCallback(
|
||
(regionId: string, referenceId: string) => {
|
||
commitLocalEditRegions(
|
||
localEditRegionsRef.current.map((region) =>
|
||
region.id === regionId
|
||
? {
|
||
...region,
|
||
referenceImages: (region.referenceImages || []).filter(
|
||
(reference) => reference.id !== referenceId
|
||
),
|
||
}
|
||
: region
|
||
)
|
||
);
|
||
},
|
||
[commitLocalEditRegions]
|
||
);
|
||
|
||
const clearLocalEditRegions = useCallback(() => {
|
||
localEditPromptDraftRefs.current = {};
|
||
localEditPromptCompositionStartRefs.current = {};
|
||
setLocalEditPromptDrafts({});
|
||
commitLocalEditRegions([]);
|
||
setEditingLocalEditRegionId(null);
|
||
}, [commitLocalEditRegions]);
|
||
|
||
const handleSubmitLocalEdit = useCallback(async () => {
|
||
if (!data.src) {
|
||
toast.error("请先上传图片");
|
||
return;
|
||
}
|
||
if (!isHttpImageUrl(data.src)) {
|
||
toast.error("局部编辑需要使用已上传到云端的图片");
|
||
return;
|
||
}
|
||
if (isSubmittingLocalEdit || isProcessing) {
|
||
toast.info("正在处理中,请稍候...");
|
||
return;
|
||
}
|
||
|
||
const 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,
|
||
localEditMode: false,
|
||
sourceLocalEditRegions: 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;
|
||
const localEditSourceWidth = Number(submitResult.data.sourceWidth || 0);
|
||
const localEditSourceHeight = Number(submitResult.data.sourceHeight || 0);
|
||
const localEditSourceRatio =
|
||
localEditSourceWidth > 0 && localEditSourceHeight > 0
|
||
? localEditSourceWidth / localEditSourceHeight
|
||
: 0;
|
||
const localEditResultSize =
|
||
localEditSourceRatio > 0
|
||
? {
|
||
width: node.size.width,
|
||
height: Math.max(80, Math.round(node.size.width / localEditSourceRatio)),
|
||
}
|
||
: node.size;
|
||
updateNode(newNodeId, {
|
||
size: localEditResultSize,
|
||
data: {
|
||
src: "",
|
||
status: "generating",
|
||
prompt: submitResult.data.prompt || `${actionName}处理中...`,
|
||
title: `${displayTitle}-${actionName}`,
|
||
originalSrc: data.src,
|
||
metadata: {
|
||
editMode: "local-edit",
|
||
sourceNodeId: node.id,
|
||
aspectRatio: submitResult.data.aspectRatio,
|
||
taskId,
|
||
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
|
||
localEditMode: false,
|
||
sourceHeight: submitResult.data.sourceHeight,
|
||
sourceLocalEditRegions: validRegions,
|
||
sourceWidth: submitResult.data.sourceWidth,
|
||
},
|
||
},
|
||
});
|
||
|
||
for (let attempt = 0; attempt < LOCAL_EDIT_MAX_POLL_ATTEMPTS; attempt += 1) {
|
||
await sleep(LOCAL_EDIT_POLL_INTERVAL_MS);
|
||
const taskResult = await imageGenerationService.getImageTask(taskId);
|
||
const task = taskResult.data?.task;
|
||
const status = String(task?.status || "").toLowerCase();
|
||
|
||
if (!taskResult.success || !task) {
|
||
continue;
|
||
}
|
||
|
||
if (status === "succeeded" || status === "completed") {
|
||
const generatedImage = task.result?.images?.[0];
|
||
const generatedUrl =
|
||
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
|
||
if (!generatedUrl) {
|
||
throw new Error("局部编辑已完成但未返回图片");
|
||
}
|
||
updateNode(newNodeId, {
|
||
data: {
|
||
src: generatedUrl,
|
||
status: "completed",
|
||
prompt: task.result?.prompt || submitResult.data.prompt || `${actionName}结果`,
|
||
title: `${displayTitle}-${actionName}`,
|
||
originalSrc: data.src,
|
||
model: task.result?.model || task.result?.modelRef || undefined,
|
||
metadata: {
|
||
editMode: "local-edit",
|
||
sourceNodeId: node.id,
|
||
aspectRatio: submitResult.data.aspectRatio,
|
||
taskId,
|
||
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
|
||
localEditMode: false,
|
||
localEditRegions: [],
|
||
sourceHeight: submitResult.data.sourceHeight,
|
||
sourceLocalEditRegions: validRegions,
|
||
sourceWidth: submitResult.data.sourceWidth,
|
||
},
|
||
},
|
||
});
|
||
resetLocalEditState();
|
||
toast.success("局部编辑结果已生成");
|
||
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,
|
||
localEditMode: false,
|
||
sourceLocalEditRegions: 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 || 640) * FLOATING_TOOLBAR_SCALE
|
||
);
|
||
const toolbarHeight = Math.max(
|
||
42,
|
||
(toolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
|
||
);
|
||
const titleSafeHeight = 34;
|
||
const gap = 12;
|
||
const { viewportPadding, maxTop, safeBottom, safeLeft, safeRight } =
|
||
getFloatingViewportBounds(toolbarHeight);
|
||
|
||
let top = rect.bottom + gap;
|
||
if (top + toolbarHeight > safeBottom) {
|
||
top = rect.top - toolbarHeight - titleSafeHeight - gap;
|
||
}
|
||
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
||
|
||
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
|
||
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
|
||
let left = rect.left + rect.width / 2 - effectiveToolbarWidth / 2;
|
||
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
|
||
left = safeLeft;
|
||
} else {
|
||
left = Math.min(
|
||
Math.max(left, safeLeft),
|
||
safeRight - effectiveToolbarWidth
|
||
);
|
||
}
|
||
|
||
return {
|
||
top,
|
||
left,
|
||
maxWidth: maxToolbarWidth,
|
||
};
|
||
}
|
||
return { top: 0, left: 0, maxWidth: 480 };
|
||
};
|
||
|
||
const getAiWashQuickPanelPosition = () => {
|
||
if (!containerRef.current || typeof window === "undefined") {
|
||
return { top: 0, left: 0, width: 286, maxHeight: 430 };
|
||
}
|
||
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
const panelWidth = 286;
|
||
const panelHeight = 430;
|
||
const gap = 12;
|
||
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
|
||
getFloatingViewportBounds(panelHeight);
|
||
const width = Math.min(panelWidth, Math.max(260, safeRight - safeLeft));
|
||
|
||
let left = rect.right + gap;
|
||
if (left + width > safeRight) {
|
||
left = rect.left - width - gap;
|
||
}
|
||
left = Math.min(Math.max(left, safeLeft), Math.max(safeLeft, safeRight - width));
|
||
|
||
let top = rect.top;
|
||
if (top + panelHeight > safeBottom) {
|
||
top = rect.bottom - panelHeight;
|
||
}
|
||
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
||
|
||
return {
|
||
top,
|
||
left,
|
||
width,
|
||
maxHeight: Math.max(260, Math.min(panelHeight, safeBottom - top)),
|
||
};
|
||
};
|
||
|
||
const getBoxCutoutToolbarPosition = () => {
|
||
if (containerRef.current && typeof window !== "undefined") {
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
const toolbarWidth = Math.max(
|
||
240,
|
||
(boxCutoutToolbarSize.width || 460) * FLOATING_TOOLBAR_SCALE
|
||
);
|
||
const toolbarHeight = Math.max(
|
||
42,
|
||
(boxCutoutToolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
|
||
);
|
||
const gap = 14;
|
||
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
|
||
getFloatingViewportBounds(toolbarHeight);
|
||
|
||
let top = rect.bottom + gap;
|
||
if (top + toolbarHeight > safeBottom) {
|
||
top = rect.top - toolbarHeight - gap;
|
||
}
|
||
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
||
|
||
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
|
||
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
|
||
let left = rect.left + rect.width / 2 - effectiveToolbarWidth / 2;
|
||
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
|
||
left = safeLeft;
|
||
} else {
|
||
left = Math.min(
|
||
Math.max(left, safeLeft),
|
||
safeRight - effectiveToolbarWidth
|
||
);
|
||
}
|
||
|
||
return {
|
||
top,
|
||
left,
|
||
maxWidth: maxToolbarWidth,
|
||
};
|
||
}
|
||
return { top: 0, left: 0, maxWidth: 460 };
|
||
};
|
||
|
||
const getImageAdjustPanelPosition = () => {
|
||
if (!containerRef.current || typeof window === "undefined") {
|
||
return { top: 0, left: 0 };
|
||
}
|
||
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
const panelWidth = 520 * IMAGE_EDIT_PANEL_SCALE;
|
||
const panelHeight = 760 * IMAGE_EDIT_PANEL_SCALE;
|
||
const spacing = 18;
|
||
const maxTop = Math.max(12, window.innerHeight - panelHeight);
|
||
|
||
let left = rect.right + spacing;
|
||
if (left + panelWidth > window.innerWidth - 12) {
|
||
left = Math.max(12, rect.left - panelWidth - spacing);
|
||
}
|
||
|
||
const top = Math.min(Math.max(12, rect.top + 10), maxTop);
|
||
return { top, left };
|
||
};
|
||
|
||
const getCropPanelPosition = () => {
|
||
if (!containerRef.current || typeof window === "undefined") {
|
||
return { top: 0, left: 0 };
|
||
}
|
||
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
const panelWidth = 474 * IMAGE_EDIT_PANEL_SCALE;
|
||
const panelHeight = 980 * IMAGE_EDIT_PANEL_SCALE;
|
||
const spacing = 16;
|
||
const maxTop = Math.max(12, window.innerHeight - panelHeight);
|
||
|
||
let left = rect.right + spacing;
|
||
if (left + panelWidth > window.innerWidth - 12) {
|
||
left = Math.max(12, rect.left - panelWidth - spacing);
|
||
}
|
||
|
||
const top = Math.min(Math.max(12, rect.top - 4), maxTop);
|
||
return { top, left };
|
||
};
|
||
|
||
const getFlipRotatePanelPosition = () => {
|
||
if (!containerRef.current || typeof window === "undefined") {
|
||
return { top: 0, left: 0, maxWidth: 660 * IMAGE_EDIT_PANEL_SCALE };
|
||
}
|
||
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
const panelWidth = 640 * IMAGE_EDIT_PANEL_SCALE;
|
||
const panelHeight = 84 * IMAGE_EDIT_PANEL_SCALE;
|
||
const gap = 18;
|
||
const titleSafeHeight = 34;
|
||
const { viewportPadding, safeLeft, safeRight, maxTop } =
|
||
getFloatingViewportBounds(panelHeight);
|
||
|
||
let top = rect.top - panelHeight - titleSafeHeight - gap;
|
||
if (top < viewportPadding) {
|
||
top = rect.bottom + gap;
|
||
}
|
||
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
||
|
||
const maxPanelWidth = Math.max(220, safeRight - safeLeft);
|
||
const effectivePanelWidth = Math.min(panelWidth, maxPanelWidth);
|
||
let left = rect.left + rect.width / 2 - effectivePanelWidth / 2;
|
||
if (effectivePanelWidth >= maxPanelWidth - 1) {
|
||
left = safeLeft;
|
||
} else {
|
||
left = Math.min(Math.max(left, safeLeft), safeRight - effectivePanelWidth);
|
||
}
|
||
|
||
return {
|
||
top,
|
||
left,
|
||
maxWidth: maxPanelWidth,
|
||
};
|
||
};
|
||
|
||
const getTextPanelPosition = () => {
|
||
if (!containerRef.current || typeof window === "undefined") {
|
||
return { top: 0, left: 0 };
|
||
}
|
||
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
const panelWidth = 360;
|
||
const spacing = 12;
|
||
const maxTop = Math.max(12, window.innerHeight - 460);
|
||
|
||
let left = rect.right + spacing;
|
||
if (left + panelWidth > window.innerWidth - 12) {
|
||
left = Math.max(12, rect.left - panelWidth - spacing);
|
||
}
|
||
|
||
const top = Math.min(Math.max(12, rect.top), maxTop);
|
||
return { top, left };
|
||
};
|
||
|
||
const getAnglePanelPosition = () => {
|
||
if (!containerRef.current || typeof window === "undefined") {
|
||
return { top: 0, left: 0 };
|
||
}
|
||
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
const panelWidth = ANGLE_PANEL_SOURCE_WIDTH * ANGLE_PANEL_SCALE;
|
||
const panelHeight = ANGLE_PANEL_SOURCE_HEIGHT * ANGLE_PANEL_SCALE;
|
||
const spacing = 16;
|
||
const maxTop = Math.max(12, window.innerHeight - panelHeight);
|
||
|
||
let left = rect.right + spacing;
|
||
if (left + panelWidth > window.innerWidth - 12) {
|
||
left = Math.max(12, rect.left - panelWidth - spacing);
|
||
}
|
||
|
||
const top = Math.min(Math.max(12, rect.top - 8), maxTop);
|
||
return { top, left };
|
||
};
|
||
|
||
const handleResetAnglePanel = useCallback(() => {
|
||
setAnglePanelMode("camera");
|
||
setAngleHorizontalAngle(-50);
|
||
setAngleVerticalAngle(-30);
|
||
setAngleZoom(50);
|
||
}, []);
|
||
|
||
const handleAnglePreviewPointerDown = useCallback(
|
||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (isApplyingAngleEdit) return;
|
||
anglePreviewDragRef.current = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
startHorizontalAngle: angleHorizontalAngle,
|
||
startVerticalAngle: angleVerticalAngle,
|
||
};
|
||
setIsAnglePreviewDragging(true);
|
||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
},
|
||
[angleHorizontalAngle, angleVerticalAngle, isApplyingAngleEdit]
|
||
);
|
||
|
||
const handleAnglePreviewPointerMove = useCallback(
|
||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
const dragState = anglePreviewDragRef.current;
|
||
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
||
const deltaX = event.clientX - dragState.startX;
|
||
const deltaY = event.clientY - dragState.startY;
|
||
setAngleHorizontalAngle(
|
||
clampNumber(
|
||
dragState.startHorizontalAngle +
|
||
deltaX * ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER,
|
||
-180,
|
||
180
|
||
)
|
||
);
|
||
setAngleVerticalAngle(
|
||
clampNumber(
|
||
dragState.startVerticalAngle -
|
||
deltaY * ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER,
|
||
-30,
|
||
60
|
||
)
|
||
);
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleAnglePreviewPointerEnd = useCallback(
|
||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (anglePreviewDragRef.current?.pointerId !== event.pointerId) return;
|
||
anglePreviewDragRef.current = null;
|
||
setIsAnglePreviewDragging(false);
|
||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleApplyFlipRotate = useCallback(
|
||
async (action: FlipRotateEditAction) => {
|
||
if (!data.src) {
|
||
toast.error("请先上传图片");
|
||
return;
|
||
}
|
||
|
||
if (isApplyingFlipRotate) {
|
||
return;
|
||
}
|
||
|
||
if (action === "reset") {
|
||
if (!hasFlipRotateReset) {
|
||
toast.info("当前图片没有可恢复的翻转记录");
|
||
return;
|
||
}
|
||
|
||
const nextMetadata =
|
||
data.metadata && typeof data.metadata === "object"
|
||
? { ...(data.metadata as Record<string, unknown>) }
|
||
: {};
|
||
delete nextMetadata.flipRotateOriginalSrc;
|
||
delete nextMetadata.flipRotateLastAction;
|
||
delete nextMetadata.flipRotateUpdatedAt;
|
||
|
||
updateNode(node.id, {
|
||
data: {
|
||
...data,
|
||
src: flipRotateOriginalSrc,
|
||
status: "completed",
|
||
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
|
||
},
|
||
});
|
||
toast.success("已恢复翻转前图片");
|
||
return;
|
||
}
|
||
|
||
setIsApplyingFlipRotate(true);
|
||
setIsProcessing(true);
|
||
|
||
try {
|
||
const nextUrl = await renderAndUploadFlippedImage(
|
||
data.src,
|
||
action,
|
||
`${displayTitle}-${action === "horizontal" ? "水平翻转" : "垂直翻转"}`
|
||
);
|
||
|
||
updateNode(node.id, {
|
||
data: {
|
||
...data,
|
||
src: nextUrl,
|
||
status: "completed",
|
||
originalSrc: data.originalSrc || data.src,
|
||
metadata: {
|
||
...(data.metadata || {}),
|
||
flipRotateOriginalSrc: flipRotateOriginalSrc || data.src,
|
||
flipRotateLastAction: action,
|
||
flipRotateUpdatedAt: new Date().toISOString(),
|
||
},
|
||
},
|
||
});
|
||
|
||
toast.success(action === "horizontal" ? "已完成水平翻转" : "已完成垂直翻转");
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "翻转失败";
|
||
toast.error(message);
|
||
} finally {
|
||
setIsApplyingFlipRotate(false);
|
||
setIsProcessing(false);
|
||
}
|
||
},
|
||
[
|
||
data,
|
||
displayTitle,
|
||
flipRotateOriginalSrc,
|
||
hasFlipRotateReset,
|
||
isApplyingFlipRotate,
|
||
node.id,
|
||
updateNode,
|
||
]
|
||
);
|
||
|
||
const handleApplyAngleEdit = useCallback(async () => {
|
||
if (!data.src) {
|
||
toast.error("请先上传图片");
|
||
return;
|
||
}
|
||
|
||
if (!isHttpImageUrl(data.src)) {
|
||
toast.error("仅支持 URL 图片,不允许 base64 传输");
|
||
return;
|
||
}
|
||
|
||
if (isApplyingAngleEdit) {
|
||
return;
|
||
}
|
||
|
||
setIsApplyingAngleEdit(true);
|
||
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,
|
||
textColor: undefined,
|
||
confidence: item.confidence,
|
||
} as ImageTextEditItem;
|
||
})
|
||
.filter((item): item is ImageTextEditItem => !!item);
|
||
|
||
setTextEditItems(detectedItems);
|
||
setDeletedTextItems([]);
|
||
|
||
updateNode(node.id, {
|
||
data: {
|
||
...data,
|
||
textEditItems: detectedItems,
|
||
textEditSource: data.src,
|
||
textEditUpdatedAt: new Date().toISOString(),
|
||
},
|
||
});
|
||
|
||
if (detectedItems.length === 0) {
|
||
toast.info("未识别到可编辑文字");
|
||
} else {
|
||
toast.success(`已识别 ${detectedItems.length} 段文字`);
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "文字识别失败";
|
||
setTextEditError(message);
|
||
toast.error(message);
|
||
} finally {
|
||
setIsDetectingText(false);
|
||
}
|
||
},
|
||
[data, isDetectingText, node.id, updateNode]
|
||
);
|
||
|
||
const handleOpenTextEdit = useCallback(async () => {
|
||
setCropPanelOpen(false);
|
||
setCropInteraction(null);
|
||
setImageAdjustPanelOpen(false);
|
||
setFlipRotatePanelOpen(false);
|
||
setAnglePanelOpen(false);
|
||
setNotePanelOpen(false);
|
||
setTextEditPanelOpen(true);
|
||
setTextEditError(null);
|
||
|
||
const cached = normalizeTextEditItems(data.textEditItems);
|
||
if (cached.length > 0 && data.textEditSource === data.src) {
|
||
setTextEditItems(cached);
|
||
setDeletedTextItems([]);
|
||
return;
|
||
}
|
||
|
||
await detectTextItems(true);
|
||
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
||
|
||
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 handleTextItemColorChange = (id: string, value?: string) => {
|
||
setTextEditItems((prev) =>
|
||
prev.map((item) =>
|
||
item.id === id
|
||
? { ...item, textColor: normalizeOptionalHexColor(value) }
|
||
: item
|
||
)
|
||
);
|
||
};
|
||
|
||
const handleRemoveTextItem = (id: string) => {
|
||
setTextEditItems((prev) => {
|
||
const target = prev.find((item) => item.id === id);
|
||
if (target?.originalText) {
|
||
setDeletedTextItems((deletedPrev) => {
|
||
if (deletedPrev.some((item) => item.id === id)) return deletedPrev;
|
||
return [...deletedPrev, { id, originalText: target.originalText }];
|
||
});
|
||
}
|
||
return prev.filter((item) => item.id !== id);
|
||
});
|
||
};
|
||
|
||
const hasTextEditChanges = useMemo(
|
||
() =>
|
||
textEditItems.some((item) => {
|
||
const originalText = item.originalText.trim();
|
||
const editedText = item.editedText.trim();
|
||
return (
|
||
originalText.length > 0 &&
|
||
(editedText.length === 0 ||
|
||
editedText !== originalText ||
|
||
Boolean(normalizeOptionalHexColor(item.textColor)))
|
||
);
|
||
}) ||
|
||
deletedTextItems.some((item) => item.originalText.trim().length > 0),
|
||
[deletedTextItems, textEditItems]
|
||
);
|
||
|
||
const handleApplyTextEdit = useCallback(async () => {
|
||
if (!data.src) {
|
||
toast.error("请先上传图片");
|
||
return;
|
||
}
|
||
|
||
if (!isHttpImageUrl(data.src)) {
|
||
toast.error("仅支持 URL 图片,不允许 base64 传输");
|
||
return;
|
||
}
|
||
|
||
const changedItems: CanvasTextEditPayloadItem[] = textEditItems
|
||
.map((item) => {
|
||
const editedText = item.editedText.trim();
|
||
const action: "replace" | "delete" =
|
||
editedText.length === 0 ? "delete" : "replace";
|
||
return {
|
||
id: item.id,
|
||
originalText: item.originalText.trim(),
|
||
editedText,
|
||
textColor: normalizeOptionalHexColor(item.textColor),
|
||
action,
|
||
};
|
||
})
|
||
.filter(
|
||
(item) =>
|
||
item.originalText.length > 0 &&
|
||
(item.action === "delete" ||
|
||
(item.editedText &&
|
||
item.editedText.length > 0 &&
|
||
(item.originalText !== item.editedText ||
|
||
Boolean(item.textColor))))
|
||
);
|
||
|
||
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
|
||
.map((item) => ({
|
||
id: item.id,
|
||
originalText: item.originalText.trim(),
|
||
action: "delete" as const,
|
||
}))
|
||
.filter((item) => item.originalText.length > 0);
|
||
|
||
const requestItems = [...changedItems, ...removedItems];
|
||
|
||
if (requestItems.length === 0) {
|
||
toast.info("请先修改文字内容、选择文字颜色,或点击垃圾桶删除该文字");
|
||
return;
|
||
}
|
||
|
||
if (isApplyingTextEdit) {
|
||
return;
|
||
}
|
||
|
||
setIsApplyingTextEdit(true);
|
||
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);
|
||
}
|
||
}
|
||
|
||
const handleSubmitAiWashQuick = useCallback(async () => {
|
||
if (!data.src) {
|
||
toast.error("请先上传图片");
|
||
return;
|
||
}
|
||
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src)) {
|
||
toast.error("图片仍在本地,上传完成后再进行 AI洗图");
|
||
return;
|
||
}
|
||
if (!aiWashQuickConfig.enabled) {
|
||
toast.error("AI洗图当前未开启,请先在系统设置中启用");
|
||
return;
|
||
}
|
||
if (!selectedAiWashQuickPreset) {
|
||
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
|
||
return;
|
||
}
|
||
if (!effectiveAiWashQuickModelRef) {
|
||
toast.error("请先选择一个可用的生图模型");
|
||
return;
|
||
}
|
||
if (isProcessing || isSubmittingAiWashQuick) {
|
||
toast.info("正在处理中,请稍候...");
|
||
return;
|
||
}
|
||
|
||
setImageAdjustPanelOpen(false);
|
||
setCropPanelOpen(false);
|
||
setCropInteraction(null);
|
||
setFlipRotatePanelOpen(false);
|
||
setAnglePanelOpen(false);
|
||
setTextEditPanelOpen(false);
|
||
setNotePanelOpen(false);
|
||
resetBoxCutoutState();
|
||
resetLocalEditState();
|
||
setIsProcessing(true);
|
||
setIsSubmittingAiWashQuick(true);
|
||
setAiWashQuickStatus("submitting");
|
||
|
||
const actionName = aiWashQuickModeLabel || "AI洗图";
|
||
const resultTitle = `${displayTitle}-${selectedAiWashQuickPreset.title}`;
|
||
const newNodeId = addNode({
|
||
type: "image",
|
||
position: {
|
||
x: node.position.x + node.size.width + 20,
|
||
y: node.position.y,
|
||
},
|
||
size: { width: node.size.width, height: node.size.height },
|
||
data: {
|
||
src: "",
|
||
status: "generating",
|
||
prompt: `${actionName}处理中...`,
|
||
title: resultTitle,
|
||
originalSrc: data.src,
|
||
metadata: {
|
||
editMode: "ai-wash",
|
||
sourceNodeId: node.id,
|
||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||
aiWashLevels: aiWashQuickLevels,
|
||
},
|
||
},
|
||
connections: [node.id],
|
||
});
|
||
selectNode(newNodeId);
|
||
|
||
try {
|
||
const submitResponse = await fetch("/api/ai-wash/generate", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt: aiWashQuickPrompt,
|
||
referenceImage: data.src,
|
||
selectedModelId: effectiveAiWashQuickModelRef,
|
||
imageSize: aiWashQuickConfig.imageSize,
|
||
promptExtend: aiWashQuickConfig.promptExtend,
|
||
title: selectedAiWashQuickPreset.title,
|
||
}),
|
||
});
|
||
const submitPayload = await submitResponse.json().catch(() => ({}));
|
||
|
||
if (
|
||
!submitResponse.ok ||
|
||
submitPayload?.success !== true ||
|
||
!submitPayload?.taskId
|
||
) {
|
||
throw new Error(submitPayload?.error || "AI洗图提交失败");
|
||
}
|
||
|
||
setAiWashQuickStatus("running");
|
||
updateNode(newNodeId, {
|
||
data: {
|
||
src: "",
|
||
status: "generating",
|
||
prompt: aiWashQuickPrompt || `${actionName}处理中...`,
|
||
title: resultTitle,
|
||
originalSrc: data.src,
|
||
metadata: {
|
||
editMode: "ai-wash",
|
||
sourceNodeId: node.id,
|
||
taskId: submitPayload.taskId,
|
||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||
aiWashLevels: aiWashQuickLevels,
|
||
aiWashImageSize: aiWashQuickConfig.imageSize,
|
||
},
|
||
},
|
||
});
|
||
|
||
const completed = await pollAiWashQuickTask(submitPayload.taskId);
|
||
const resolvedModelLabel =
|
||
completed.modelId ||
|
||
selectedAiWashQuickModel?.displayName ||
|
||
selectedAiWashQuickModel?.modelId ||
|
||
effectiveAiWashQuickModelRef;
|
||
|
||
updateNode(newNodeId, {
|
||
data: {
|
||
src: completed.imageUrl,
|
||
status: "completed",
|
||
prompt: completed.prompt || aiWashQuickPrompt || `${actionName}结果`,
|
||
title: resultTitle,
|
||
originalSrc: data.src,
|
||
model: resolvedModelLabel,
|
||
metadata: {
|
||
editMode: "ai-wash",
|
||
sourceNodeId: node.id,
|
||
taskId: submitPayload.taskId,
|
||
thumbnailUrl: completed.thumbnailUrl,
|
||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||
aiWashLevels: aiWashQuickLevels,
|
||
aiWashImageSize: aiWashQuickConfig.imageSize,
|
||
modelId: resolvedModelLabel,
|
||
},
|
||
},
|
||
});
|
||
setAiWashQuickStatus("succeeded");
|
||
setAiWashQuickPanelOpen(false);
|
||
toast.success("AI洗图结果已生成到画布");
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "AI洗图失败";
|
||
console.error("[ImageNode] AI洗图快捷生成失败:", error);
|
||
updateNode(newNodeId, {
|
||
data: {
|
||
src: "",
|
||
status: "failed",
|
||
prompt: `${actionName}失败`,
|
||
title: `${resultTitle}-失败`,
|
||
originalSrc: data.src,
|
||
metadata: {
|
||
editMode: "ai-wash",
|
||
sourceNodeId: node.id,
|
||
error: message,
|
||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||
aiWashLevels: aiWashQuickLevels,
|
||
},
|
||
},
|
||
});
|
||
setAiWashQuickStatus("failed");
|
||
toast.error(message);
|
||
} finally {
|
||
setIsProcessing(false);
|
||
setIsSubmittingAiWashQuick(false);
|
||
}
|
||
}, [
|
||
addNode,
|
||
aiWashQuickConfig.enabled,
|
||
aiWashQuickConfig.imageSize,
|
||
aiWashQuickConfig.promptExtend,
|
||
aiWashQuickLevels,
|
||
aiWashQuickModeLabel,
|
||
aiWashQuickPrompt,
|
||
data.src,
|
||
displayTitle,
|
||
effectiveAiWashQuickModelRef,
|
||
isProcessing,
|
||
isSubmittingAiWashQuick,
|
||
node,
|
||
resetBoxCutoutState,
|
||
resetLocalEditState,
|
||
selectNode,
|
||
selectedAiWashQuickModel?.displayName,
|
||
selectedAiWashQuickModel?.modelId,
|
||
selectedAiWashQuickPreset,
|
||
updateNode,
|
||
]);
|
||
|
||
// 处理图片编辑操作 - 集成我们现有的 API
|
||
const handleAction = async (action: string) => {
|
||
if (!data.src) {
|
||
toast.error("请先上传图片");
|
||
return;
|
||
}
|
||
if (
|
||
AI_CLOUD_IMAGE_ACTIONS.has(action) &&
|
||
(isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src))
|
||
) {
|
||
toast.error("图片仍在本地,上传完成后再进行 AI 处理");
|
||
return;
|
||
}
|
||
if (isProcessing) {
|
||
toast.info("正在处理中,请稍候...");
|
||
return;
|
||
}
|
||
if (action === "ai-wash") {
|
||
setCropPanelOpen(false);
|
||
setCropInteraction(null);
|
||
setImageAdjustPanelOpen(false);
|
||
setFlipRotatePanelOpen(false);
|
||
setAnglePanelOpen(false);
|
||
setTextEditPanelOpen(false);
|
||
setNotePanelOpen(false);
|
||
resetBoxCutoutState();
|
||
resetLocalEditState();
|
||
setAiWashQuickPanelOpen((current) => !current);
|
||
return;
|
||
}
|
||
setAiWashQuickPanelOpen(false);
|
||
if (action !== "local-edit") {
|
||
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`);
|
||
formData.append("purpose", "canvas-upload");
|
||
const uploadResponse = await fetchUploadWithRetry(formData);
|
||
if (uploadResponse.ok) {
|
||
const result = await uploadResponse.json();
|
||
finalUrl = result.url;
|
||
} else {
|
||
toast.error("上传失败");
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error("上传标注图片失败:", error);
|
||
toast.error("上传失败");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 更新当前节点,标记为已标注图片
|
||
// 标注后的图片会带有视觉标记(箭头、圆圈、文字等)
|
||
// 这些标记指示了用户希望 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`);
|
||
formData.append("purpose", "canvas-upload");
|
||
|
||
const uploadResponse = await fetchUploadWithRetry(formData);
|
||
|
||
if (!uploadResponse.ok) {
|
||
throw new Error("上传失败");
|
||
}
|
||
|
||
const result = await uploadResponse.json();
|
||
return result.url as string;
|
||
}, []);
|
||
|
||
const handleResizeEditorComplete = useCallback(
|
||
async (resizedImageUrl: string) => {
|
||
let finalUrl = resizedImageUrl;
|
||
|
||
if (resizedImageUrl.startsWith("data:")) {
|
||
try {
|
||
toast.info("正在上传尺寸调整结果...");
|
||
finalUrl = await uploadBase64ToStorage(resizedImageUrl);
|
||
} catch (error) {
|
||
console.error("上传尺寸调整图片失败:", error);
|
||
toast.error("尺寸调整结果上传失败");
|
||
return;
|
||
}
|
||
}
|
||
|
||
const title = `${displayTitle}-尺寸调整`;
|
||
addNode({
|
||
type: "image",
|
||
position: {
|
||
x: node.position.x + node.size.width + 20,
|
||
y: node.position.y,
|
||
},
|
||
size: { width: node.size.width, height: node.size.height },
|
||
data: {
|
||
src: finalUrl,
|
||
status: "completed",
|
||
prompt: "尺寸调整结果",
|
||
title,
|
||
originalSrc: data.src,
|
||
},
|
||
connections: [node.id],
|
||
});
|
||
|
||
setResizeAnnotatorOpen(false);
|
||
toast.success("尺寸调整结果已发送到画布");
|
||
},
|
||
[
|
||
addNode,
|
||
data.src,
|
||
displayTitle,
|
||
node.id,
|
||
node.position.x,
|
||
node.position.y,
|
||
node.size.height,
|
||
node.size.width,
|
||
uploadBase64ToStorage,
|
||
]
|
||
);
|
||
|
||
const persistCanvasStateSnapshot = useCallback(() => {
|
||
if (!projectId || typeof window === "undefined") return;
|
||
window.setTimeout(() => {
|
||
void canvasService.saveCanvasState(projectId, getCanvasState());
|
||
}, 0);
|
||
}, [getCanvasState, projectId]);
|
||
|
||
const persistCanvas3DAsset = useCallback(
|
||
async (asset: Record<string, any>) => {
|
||
if (!projectId) return;
|
||
try {
|
||
const response = await fetch(`/api/canvas/${projectId}/model3d-assets`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ asset }),
|
||
});
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => ({}));
|
||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||
}
|
||
} catch (error) {
|
||
console.error("[ImageNode] 保存画布 3D 资产失败:", error);
|
||
}
|
||
},
|
||
[projectId]
|
||
);
|
||
|
||
const pollCanvasModel3DTask = useCallback(
|
||
async (params: {
|
||
nodeId: string;
|
||
taskId: string;
|
||
providerId?: string | null;
|
||
providerName?: string | null;
|
||
promptText: string;
|
||
modelVersion: string;
|
||
sourceImageUrl: string;
|
||
previewImageUrl?: string | null;
|
||
}) => {
|
||
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
|
||
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
|
||
const result = await model3DGenerationService.getTask(params.taskId, {
|
||
providerId: params.providerId,
|
||
});
|
||
const status = String(result.data?.status || "unknown");
|
||
const progress = result.data?.progress ?? null;
|
||
|
||
if (!result.success || !result.data) {
|
||
const error = result.error || "3D 模型任务查询失败";
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
src: "",
|
||
previewImage: params.previewImageUrl || params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "failed",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
mode: "image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
error,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
status: "failed",
|
||
mode: "image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress,
|
||
modelUrl: null,
|
||
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
|
||
error,
|
||
assetPayload: {
|
||
providerId: params.providerId || null,
|
||
providerName: params.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.error(error);
|
||
return;
|
||
}
|
||
|
||
if (
|
||
status === "failed" ||
|
||
status === "error" ||
|
||
status === "cancelled" ||
|
||
status === "canceled"
|
||
) {
|
||
const error = result.data.errorMessage || "3D 模型任务失败";
|
||
const refundedPoints = Number(result.data.refundedPoints || 0);
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
src: "",
|
||
previewImage: params.previewImageUrl || params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "failed",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
mode: "image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress,
|
||
error,
|
||
errorCode: result.data.errorCode ?? null,
|
||
refundedPoints,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
status: "failed",
|
||
mode: "image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress,
|
||
modelUrl: null,
|
||
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
|
||
error,
|
||
assetPayload: {
|
||
providerId: params.providerId || null,
|
||
providerName: params.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
errorCode: result.data.errorCode ?? null,
|
||
refundedPoints,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.error(
|
||
refundedPoints > 0
|
||
? `${error},已退回 ${refundedPoints} 积分`
|
||
: error
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (status === "success") {
|
||
const modelUrl = String(result.data.modelUrl || "").trim();
|
||
const previewImageUrl =
|
||
result.data.previewImageUrl ||
|
||
params.previewImageUrl ||
|
||
params.sourceImageUrl;
|
||
const responseAssetPayload =
|
||
result.data.assetPayload &&
|
||
typeof result.data.assetPayload === "object" &&
|
||
!Array.isArray(result.data.assetPayload)
|
||
? (result.data.assetPayload as Record<string, unknown>)
|
||
: null;
|
||
const nextAssetPayload = {
|
||
...(responseAssetPayload || {}),
|
||
providerId:
|
||
params.providerId ||
|
||
(typeof responseAssetPayload?.providerId === "string"
|
||
? responseAssetPayload.providerId
|
||
: null),
|
||
providerName:
|
||
params.providerName ||
|
||
(typeof responseAssetPayload?.providerName === "string"
|
||
? responseAssetPayload.providerName
|
||
: null),
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
};
|
||
if (!modelUrl) {
|
||
const error = "3D 模型已完成,但没有返回模型文件";
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
src: "",
|
||
previewImage: previewImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "failed",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
mode: "image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress,
|
||
error,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
status: "failed",
|
||
mode: "image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress,
|
||
modelUrl: null,
|
||
previewImageUrl,
|
||
error,
|
||
assetPayload: {
|
||
providerId: params.providerId || null,
|
||
providerName: params.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.error(error);
|
||
return;
|
||
}
|
||
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
src: modelUrl,
|
||
previewImage: previewImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
format: inferModelFormat(modelUrl),
|
||
status: "completed",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
mode: "image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress: 100,
|
||
output: result.data.output || null,
|
||
assetPayload: nextAssetPayload,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
status: "success",
|
||
mode: "image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress: 100,
|
||
modelUrl,
|
||
previewImageUrl,
|
||
error: null,
|
||
assetPayload: nextAssetPayload,
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.success("3D 模型已生成到画布");
|
||
return;
|
||
}
|
||
|
||
if (isTerminal3DFailureStatus(status)) {
|
||
const error = result.data.errorMessage || "3D 模型生成失败";
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
src: "",
|
||
previewImage: params.previewImageUrl || params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "failed",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
mode: "image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress,
|
||
error,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
status: "failed",
|
||
mode: "image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress,
|
||
modelUrl: null,
|
||
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
|
||
error,
|
||
assetPayload: {
|
||
providerId: params.providerId || null,
|
||
providerName: params.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.error(error);
|
||
return;
|
||
}
|
||
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
src: "",
|
||
previewImage: params.previewImageUrl || params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "generating",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
mode: "image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress,
|
||
},
|
||
},
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
}
|
||
|
||
const error = "3D 模型任务轮询超时,请稍后重试";
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
src: "",
|
||
previewImage: params.previewImageUrl || params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "failed",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
mode: "image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
error,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "tripo-3d",
|
||
status: "failed",
|
||
mode: "image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress: null,
|
||
modelUrl: null,
|
||
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
|
||
error,
|
||
assetPayload: {
|
||
providerId: params.providerId || null,
|
||
providerName: params.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.error(error);
|
||
},
|
||
[persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode]
|
||
);
|
||
|
||
const pollCanvasWorld3DTask = useCallback(
|
||
async (params: {
|
||
nodeId: string;
|
||
taskId: string;
|
||
providerId?: string | null;
|
||
providerName?: string | null;
|
||
promptText: string;
|
||
modelVersion: string;
|
||
sourceImageUrl: string;
|
||
}) => {
|
||
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
|
||
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
|
||
const result = await worldLabsGenerationService.getTask(params.taskId, {
|
||
providerId: params.providerId,
|
||
});
|
||
const status = String(result.data?.status || "unknown");
|
||
const progress = result.data?.progress ?? null;
|
||
|
||
if (!result.success || !result.data) {
|
||
const error = result.error || "3D 世界任务查询失败";
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
imageUrl: params.sourceImageUrl,
|
||
previewImage: params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "failed",
|
||
activeView: "image",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
mode: "world-image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
error,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
status: "failed",
|
||
mode: "world-image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress,
|
||
modelUrl: null,
|
||
previewImageUrl: params.sourceImageUrl,
|
||
error,
|
||
assetPayload: {
|
||
providerId: params.providerId || null,
|
||
providerName: params.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.error(error);
|
||
return;
|
||
}
|
||
|
||
if (status === "success") {
|
||
const assetPayload = (result.data.assetPayload || {}) as Record<string, any>;
|
||
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
|
||
assetPayload.spzUrls as Record<string, string> | null | undefined
|
||
);
|
||
const returnedPanoUrl =
|
||
typeof assetPayload.panoUrl === "string" && assetPayload.panoUrl.trim()
|
||
? assetPayload.panoUrl.trim()
|
||
: null;
|
||
const returnedThumbnailUrl =
|
||
typeof assetPayload.thumbnailUrl === "string" && assetPayload.thumbnailUrl.trim()
|
||
? assetPayload.thumbnailUrl.trim()
|
||
: null;
|
||
const returnedPreviewImageUrl =
|
||
typeof result.data.previewImageUrl === "string" &&
|
||
result.data.previewImageUrl.trim()
|
||
? result.data.previewImageUrl.trim()
|
||
: null;
|
||
const rawReturnedMeshUrl =
|
||
result.data.modelUrl ||
|
||
(typeof assetPayload.meshUrl === "string" && assetPayload.meshUrl.trim()
|
||
? assetPayload.meshUrl.trim()
|
||
: null);
|
||
const returnedMeshUrl = isArchivedWorldLabsAssetUrl(rawReturnedMeshUrl)
|
||
? rawReturnedMeshUrl
|
||
: null;
|
||
const hasRenderableWorldAsset = Boolean(
|
||
splatUrl ||
|
||
returnedPanoUrl ||
|
||
returnedThumbnailUrl ||
|
||
returnedPreviewImageUrl ||
|
||
returnedMeshUrl
|
||
);
|
||
if (!hasRenderableWorldAsset) {
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
imageUrl: params.sourceImageUrl,
|
||
previewImage: params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "generating",
|
||
activeView: "image",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
mode: "world-image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress: 95,
|
||
assetPayload,
|
||
},
|
||
},
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
continue;
|
||
}
|
||
const imageUrl =
|
||
returnedPanoUrl ||
|
||
returnedThumbnailUrl ||
|
||
returnedPreviewImageUrl ||
|
||
params.sourceImageUrl;
|
||
const meshUrl = returnedMeshUrl;
|
||
const worldUrl =
|
||
typeof assetPayload.worldUrl === "string" ? assetPayload.worldUrl : null;
|
||
const previewImageUrl = result.data.previewImageUrl || imageUrl;
|
||
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
worldUrl: worldUrl || undefined,
|
||
imageUrl: imageUrl || undefined,
|
||
meshUrl: meshUrl || undefined,
|
||
splatUrl: splatUrl || undefined,
|
||
previewImage: previewImageUrl || undefined,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "completed",
|
||
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
|
||
firstPerson: false,
|
||
hotspots: [],
|
||
semanticsMetadata:
|
||
assetPayload.semanticsMetadata &&
|
||
typeof assetPayload.semanticsMetadata === "object"
|
||
? assetPayload.semanticsMetadata
|
||
: undefined,
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
mode: "world-image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress: 100,
|
||
assetPayload: {
|
||
...assetPayload,
|
||
providerId: params.providerId || assetPayload.providerId || null,
|
||
providerName:
|
||
params.providerName || assetPayload.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
status: "success",
|
||
mode: "world-image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress: 100,
|
||
modelUrl: meshUrl,
|
||
previewImageUrl,
|
||
error: null,
|
||
assetPayload: {
|
||
...assetPayload,
|
||
providerId: params.providerId || assetPayload.providerId || null,
|
||
providerName: params.providerName || assetPayload.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.success("3D 世界已生成到画布");
|
||
return;
|
||
}
|
||
|
||
if (isTerminal3DFailureStatus(status)) {
|
||
const error = result.data.errorMessage || "3D 世界生成失败";
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
imageUrl: params.sourceImageUrl,
|
||
previewImage: params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "failed",
|
||
activeView: "image",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
mode: "world-image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress,
|
||
error,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
status: "failed",
|
||
mode: "world-image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress,
|
||
modelUrl: null,
|
||
previewImageUrl: params.sourceImageUrl,
|
||
error,
|
||
assetPayload: {
|
||
providerId: params.providerId || null,
|
||
providerName: params.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.error(error);
|
||
return;
|
||
}
|
||
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
imageUrl: params.sourceImageUrl,
|
||
previewImage: params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "generating",
|
||
activeView: "image",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
mode: "world-image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
progress,
|
||
},
|
||
},
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
}
|
||
|
||
const error = "3D 世界任务轮询超时,请稍后重试";
|
||
updateNode(params.nodeId, {
|
||
data: {
|
||
imageUrl: params.sourceImageUrl,
|
||
previewImage: params.sourceImageUrl,
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
status: "failed",
|
||
activeView: "image",
|
||
metadata: {
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
mode: "world-image",
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
error,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: params.taskId,
|
||
provider: "worldlabs-3d",
|
||
status: "failed",
|
||
mode: "world-image",
|
||
prompt: params.promptText,
|
||
modelVersion: params.modelVersion,
|
||
progress: null,
|
||
modelUrl: null,
|
||
previewImageUrl: params.sourceImageUrl,
|
||
error,
|
||
assetPayload: {
|
||
providerId: params.providerId || null,
|
||
providerName: params.providerName || null,
|
||
sourceImageUrl: params.sourceImageUrl,
|
||
},
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.error(error);
|
||
},
|
||
[persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode]
|
||
);
|
||
|
||
const handleGenerateCanvas3DModel = useCallback(async (requestedModelVersion?: string) => {
|
||
if (!data.src || !isHttpImageUrl(data.src)) {
|
||
toast.error("请选择一张可访问的 URL 图片");
|
||
return;
|
||
}
|
||
if (!projectId) {
|
||
toast.error("画布还在加载,请稍后再试");
|
||
return;
|
||
}
|
||
|
||
const promptText = (data.prompt || displayTitle || "图生 3D 模型").trim();
|
||
const modelVersion =
|
||
requestedModelVersion || DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard";
|
||
const sourceImageUrl = data.src;
|
||
const newNodeId = addNode({
|
||
type: "model3d",
|
||
position: {
|
||
x: node.position.x + node.size.width + 24,
|
||
y: node.position.y,
|
||
},
|
||
size: { width: Math.max(360, node.size.width), height: 300 },
|
||
data: {
|
||
src: "",
|
||
previewImage: sourceImageUrl,
|
||
prompt: promptText,
|
||
modelVersion,
|
||
format: "glb",
|
||
status: "generating",
|
||
metadata: {
|
||
provider: "tripo-3d",
|
||
mode: "image",
|
||
sourceImageUrl,
|
||
},
|
||
},
|
||
connections: [node.id],
|
||
});
|
||
selectNode(newNodeId);
|
||
persistCanvasStateSnapshot();
|
||
|
||
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,
|
||
generationStartedAt: new Date().toISOString(),
|
||
},
|
||
},
|
||
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,
|
||
generationStartedAt: new Date().toISOString(),
|
||
assetPayload: createAssetPayload,
|
||
},
|
||
},
|
||
});
|
||
await persistCanvas3DAsset({
|
||
taskId: createResult.data.taskId,
|
||
provider: "worldlabs-3d",
|
||
status: createResult.data.status || "running",
|
||
mode: "world-image",
|
||
prompt: promptText,
|
||
modelVersion,
|
||
progress: null,
|
||
modelUrl: null,
|
||
previewImageUrl: sourceImageUrl,
|
||
error: null,
|
||
assetPayload: createAssetPayload,
|
||
createdAt: Date.now(),
|
||
});
|
||
persistCanvasStateSnapshot();
|
||
toast.success("已在画布创建 3D 世界任务");
|
||
void pollCanvasWorld3DTask({
|
||
nodeId: newNodeId,
|
||
taskId: createResult.data.taskId,
|
||
providerId,
|
||
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);
|
||
}
|
||
};
|
||
|
||
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
|
||
const handleDownload = async () => {
|
||
if (!data.src) return;
|
||
const originalUrl = String(resolveOriginalImageUrl(data.src) || data.src || "").trim();
|
||
const downloadCandidates = Array.from(
|
||
new Set(
|
||
[
|
||
originalUrl,
|
||
data.src,
|
||
displayImageSrc,
|
||
fullscreenPreviewSrc,
|
||
buildImageProxyUrl(originalUrl || data.src || "", {
|
||
delivery: "proxy",
|
||
}),
|
||
]
|
||
.map((value) => String(value || "").trim())
|
||
.filter(Boolean)
|
||
)
|
||
);
|
||
if (!originalUrl) {
|
||
toast.error("下载失败,请重试");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
toast.info("正在生成 PS 兼容 PNG...");
|
||
|
||
const response = await fetch("/api/canvas/download-image", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
url: downloadCandidates[0],
|
||
urls: downloadCandidates,
|
||
name: displayTitle,
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
const payload = (await response.json().catch(() => null)) as {
|
||
error?: string;
|
||
} | null;
|
||
throw new Error(payload?.error || `下载失败(${response.status})`);
|
||
}
|
||
const blob = await response.blob();
|
||
if (!blob.type.toLowerCase().includes("image/png")) {
|
||
throw new Error("下载结果不是标准 PNG");
|
||
}
|
||
const filename =
|
||
parseAttachmentFilename(response.headers.get("content-disposition")) ||
|
||
`${sanitizeFileName(displayTitle)}.png`;
|
||
|
||
// 创建 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);
|
||
toast.error(
|
||
error instanceof Error
|
||
? `${error.message},源图可能已失效,请稍后重试或重新生成图片`
|
||
: "图片源文件暂时不可访问,请稍后重试或重新生成图片"
|
||
);
|
||
}
|
||
};
|
||
|
||
const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => {
|
||
if (boxCutoutMetrics) {
|
||
return {
|
||
left: boxCutoutMetrics.offsetX,
|
||
top: boxCutoutMetrics.offsetY,
|
||
width: boxCutoutMetrics.renderedWidth,
|
||
height: boxCutoutMetrics.renderedHeight,
|
||
};
|
||
}
|
||
return { inset: 0 };
|
||
}, [boxCutoutMetrics]);
|
||
|
||
const renderImageAdjustmentOverlays = useCallback(() => {
|
||
return (
|
||
<ImageNodeAdjustOverlays
|
||
adjustments={activeImageAdjustments}
|
||
hasActiveAdjustments={hasActiveImageAdjustments}
|
||
style={imageAdjustOverlayStyle}
|
||
/>
|
||
);
|
||
}, [
|
||
activeImageAdjustments,
|
||
hasActiveImageAdjustments,
|
||
imageAdjustOverlayStyle,
|
||
]);
|
||
|
||
const cropPanelPosition = getCropPanelPosition();
|
||
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
|
||
const toolbarPosition = getToolbarPosition();
|
||
const aiWashQuickPanelPosition = getAiWashQuickPanelPosition();
|
||
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
|
||
const flipRotatePanelPosition = getFlipRotatePanelPosition();
|
||
const anglePanelPosition = getAnglePanelPosition();
|
||
const boxCutoutHintText = boxCutoutSelection
|
||
? "确认后将仅抠出框选区域"
|
||
: isBoxCutoutDrawing
|
||
? "松开完成框选"
|
||
: "在图片中拖拽矩形框";
|
||
|
||
return (
|
||
<>
|
||
{/* 选中时在图片上方显示标题 */}
|
||
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
|
||
<ImageNodeSelectedTitle
|
||
displayTitle={displayTitle}
|
||
draftTitle={draftTitle}
|
||
isEditingTitle={isEditingTitle}
|
||
onCancelEditing={() => setIsEditingTitle(false)}
|
||
onDraftTitleChange={setDraftTitle}
|
||
onSaveTitle={handleSaveTitle}
|
||
onStartEditing={() => setIsEditingTitle(true)}
|
||
resolutionLabel={resolutionLabel}
|
||
style={selectedTitleStyle}
|
||
/>
|
||
)}
|
||
|
||
<div
|
||
className={cn(
|
||
"relative h-full w-full rounded-none border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_16px_34px_rgba(22,24,29,0.08)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
|
||
localEditMode ? "overflow-visible" : "overflow-hidden",
|
||
isRecentlyGenerated &&
|
||
"border-[#7c9cff] shadow-[0_0_0_1px_rgba(124,156,255,0.42),0_0_0_8px_rgba(124,156,255,0.12),0_18px_42px_rgba(91,118,254,0.22)]",
|
||
isSelected
|
||
? "border-[#5b76fe] ring-2 ring-[#5b76fe]/14 shadow-[0_0_0_1px_rgba(91,118,254,0.14),0_20px_38px_rgba(91,118,254,0.12)]"
|
||
: "hover:border-[#cfd9ea]"
|
||
)}
|
||
onMouseEnter={() => setIsHovered(true)}
|
||
onMouseLeave={() => setIsHovered(false)}
|
||
ref={containerRef}
|
||
>
|
||
{isRecentlyGenerated ? (
|
||
<div
|
||
className="pointer-events-none absolute -inset-2 z-[3] rounded-[inherit] border border-[#8fb0ff]/55"
|
||
style={{ animation: "canvas-generated-pulse 1.35s ease-out 0s 4" }}
|
||
/>
|
||
) : null}
|
||
<div className="relative h-full w-full" style={scaledContentStyle}>
|
||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||
{data.src ? (
|
||
<div className="relative h-full w-full">
|
||
{!shouldRequestDisplayImage ? (
|
||
<ImageNodeDeferredLoadState />
|
||
) : isCompareMode && hasCompareSource ? (
|
||
<div
|
||
className="relative h-full w-full cursor-col-resize select-none overflow-hidden bg-slate-50 dark:bg-zinc-900"
|
||
data-canvas-no-drag="true"
|
||
onPointerDown={(event) => {
|
||
event.stopPropagation();
|
||
updateCompareSplitByClientX(event.clientX);
|
||
}}
|
||
onPointerMove={(event) => {
|
||
updateCompareSplitByClientX(event.clientX);
|
||
}}
|
||
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}%` }}
|
||
>
|
||
<>
|
||
</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-none border-[1.5px] bg-transparent shadow-none",
|
||
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,
|
||
}}
|
||
>
|
||
{(
|
||
[
|
||
{
|
||
handle: "nw",
|
||
className: "-left-[5px] -top-[5px] cursor-nwse-resize",
|
||
},
|
||
{
|
||
handle: "ne",
|
||
className: "-right-[5px] -top-[5px] cursor-nesw-resize",
|
||
},
|
||
{
|
||
handle: "sw",
|
||
className: "-left-[5px] -bottom-[5px] cursor-nesw-resize",
|
||
},
|
||
{
|
||
handle: "se",
|
||
className: "-right-[5px] -bottom-[5px] cursor-nwse-resize",
|
||
},
|
||
] as const
|
||
).map((item) => (
|
||
<button
|
||
aria-label={`调整区域 ${region.index}`}
|
||
className={cn(
|
||
"nodrag nopan nowheel absolute h-[10px] w-[10px] rounded-none border-[1.5px] bg-white shadow-none",
|
||
item.className
|
||
)}
|
||
data-canvas-no-drag="true"
|
||
data-local-edit-control="true"
|
||
data-local-edit-region-handle={item.handle}
|
||
key={item.handle}
|
||
style={{ borderColor: region.color }}
|
||
type="button"
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
{typeof document !== "undefined" && localEditPromptPortalFrame
|
||
? createPortal(
|
||
<div
|
||
className="pointer-events-none fixed z-[9998] overflow-visible"
|
||
data-local-edit-control="true"
|
||
style={{
|
||
left: localEditPromptPortalFrame.left,
|
||
top: localEditPromptPortalFrame.top,
|
||
width: localEditPromptPortalFrame.width,
|
||
height: localEditPromptPortalFrame.height,
|
||
}}
|
||
>
|
||
{localEditPromptViewportOverlays.map(
|
||
({ region, isEditing, style }) => (
|
||
<div
|
||
className="nodrag nopan nowheel pointer-events-auto absolute z-[170] flex flex-col items-stretch gap-1.5"
|
||
data-canvas-no-drag="true"
|
||
data-local-edit-control="true"
|
||
key={`local-edit-prompt-${region.id}`}
|
||
onClick={(event) => event.stopPropagation()}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
style={style}
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
{isEditing ? (
|
||
<input
|
||
autoFocus={editingLocalEditRegionId === region.id}
|
||
className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium leading-5 text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18"
|
||
data-canvas-no-drag="true"
|
||
data-local-edit-control="true"
|
||
autoComplete="off"
|
||
enterKeyHint="done"
|
||
inputMode="text"
|
||
lang="zh-CN"
|
||
defaultValue={
|
||
localEditPromptDrafts[region.id] ??
|
||
localEditPromptDraftRefs.current[region.id] ??
|
||
region.prompt
|
||
}
|
||
onBlur={(event) => {
|
||
const nextPrompt =
|
||
localEditPromptDraftRefs.current[region.id] ??
|
||
event.currentTarget.value;
|
||
event.currentTarget.value = nextPrompt;
|
||
persistLocalEditRegionPrompt(region.id, nextPrompt);
|
||
}}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
}}
|
||
onCompositionEnd={(event) => {
|
||
finishLocalEditPromptComposition(
|
||
region.id,
|
||
event.currentTarget,
|
||
event.data
|
||
);
|
||
}}
|
||
onCompositionStart={(event) => {
|
||
localEditPromptCompositionStartRefs.current[
|
||
region.id
|
||
] = {
|
||
end:
|
||
event.currentTarget.selectionEnd ??
|
||
event.currentTarget.value.length,
|
||
start:
|
||
event.currentTarget.selectionStart ??
|
||
event.currentTarget.value.length,
|
||
value:
|
||
localEditPromptDraftRefs.current[region.id] ??
|
||
event.currentTarget.value,
|
||
};
|
||
}}
|
||
onFocus={(event) => {
|
||
event.stopPropagation();
|
||
setEditingLocalEditRegionId((current) =>
|
||
current === region.id ? current : region.id
|
||
);
|
||
}}
|
||
onChange={(event) => {
|
||
updateLocalEditPromptDraftRef(
|
||
region.id,
|
||
event.currentTarget.value
|
||
);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
const isComposing =
|
||
event.nativeEvent.isComposing ||
|
||
event.key === "Process";
|
||
if (isComposing) {
|
||
return;
|
||
}
|
||
if (
|
||
applyLocalEditPromptKeyEdit(
|
||
region.id,
|
||
event.currentTarget,
|
||
event
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
if (
|
||
event.key === "Enter" &&
|
||
(event.metaKey || event.ctrlKey)
|
||
) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
persistLocalEditRegionPrompt(
|
||
region.id,
|
||
event.currentTarget.value
|
||
);
|
||
setEditingLocalEditRegionId(null);
|
||
}
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
persistLocalEditRegionPrompt(
|
||
region.id,
|
||
event.currentTarget.value
|
||
);
|
||
setEditingLocalEditRegionId(null);
|
||
}
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.stopPropagation();
|
||
}}
|
||
onPointerDown={(event) => {
|
||
event.stopPropagation();
|
||
}}
|
||
placeholder={`区域 ${region.index} 提示词`}
|
||
ref={(input) => {
|
||
localEditPromptInputRefs.current[region.id] = input;
|
||
}}
|
||
spellCheck={false}
|
||
type="text"
|
||
/>
|
||
) : (
|
||
<button
|
||
className="nodrag nopan nowheel h-9 min-w-0 flex-1 truncate rounded-full border border-slate-200 bg-white/95 px-3 text-left text-[12px] font-semibold text-slate-900 shadow-[0_10px_24px_rgba(15,23,42,0.14)] hover:bg-slate-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-slate-900"
|
||
data-canvas-no-drag="true"
|
||
data-local-edit-control="true"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
setEditingLocalEditRegionId(region.id);
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
}}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
setEditingLocalEditRegionId(region.id);
|
||
}}
|
||
title={region.prompt}
|
||
type="button"
|
||
>
|
||
{region.index}. {region.prompt}
|
||
</button>
|
||
)}
|
||
<input
|
||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||
className="hidden"
|
||
data-canvas-no-drag="true"
|
||
data-local-edit-control="true"
|
||
id={`local-edit-reference-upload-${region.id}`}
|
||
multiple
|
||
onChange={(event) => {
|
||
void handleLocalEditReferenceUpload(
|
||
region.id,
|
||
event.currentTarget.files
|
||
);
|
||
event.currentTarget.value = "";
|
||
}}
|
||
type="file"
|
||
/>
|
||
<button
|
||
className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950"
|
||
data-canvas-no-drag="true"
|
||
data-local-edit-control="true"
|
||
disabled={
|
||
uploadingLocalEditReferenceRegionId === region.id ||
|
||
(region.referenceImages?.length || 0) >=
|
||
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION
|
||
}
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
document
|
||
.getElementById(
|
||
`local-edit-reference-upload-${region.id}`
|
||
)
|
||
?.click();
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
}}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
}}
|
||
title="添加区域参考图"
|
||
type="button"
|
||
>
|
||
{uploadingLocalEditReferenceRegionId === region.id ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<ImagePlus className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
<button
|
||
className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-500 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-red-500 hover:text-white dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)]"
|
||
data-canvas-no-drag="true"
|
||
data-local-edit-control="true"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
deleteLocalEditRegion(region.id);
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
}}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
deleteLocalEditRegion(region.id);
|
||
}}
|
||
title="删除区域"
|
||
type="button"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
{region.referenceImages?.length ? (
|
||
<div className="flex max-w-full items-center gap-1 overflow-hidden rounded-2xl border border-slate-200 bg-white/92 px-1.5 py-1 shadow-[0_8px_20px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-slate-950/80">
|
||
<span className="shrink-0 px-1 text-[10px] font-semibold text-slate-500 dark:text-white/52">
|
||
参考
|
||
</span>
|
||
{region.referenceImages.map((reference) => (
|
||
<div
|
||
className="group relative h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-slate-100 dark:border-white/12 dark:bg-slate-800"
|
||
key={reference.id}
|
||
title={reference.name || "区域参考图"}
|
||
>
|
||
<img
|
||
alt={reference.name || "区域参考图"}
|
||
className="h-full w-full object-cover"
|
||
draggable={false}
|
||
src={reference.url}
|
||
/>
|
||
<button
|
||
className="absolute inset-0 hidden items-center justify-center bg-black/52 text-white group-hover:flex"
|
||
data-canvas-no-drag="true"
|
||
data-local-edit-control="true"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
removeLocalEditReferenceImage(
|
||
region.id,
|
||
reference.id
|
||
);
|
||
}}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
event.nativeEvent.stopImmediatePropagation?.();
|
||
event.stopPropagation();
|
||
removeLocalEditReferenceImage(
|
||
region.id,
|
||
reference.id
|
||
);
|
||
}}
|
||
title="移除参考图"
|
||
type="button"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
)}
|
||
</div>,
|
||
document.body
|
||
)
|
||
: null}
|
||
{localEditDraftRectStyle ? (
|
||
<div
|
||
className="pointer-events-none absolute z-[162] rounded-none border-[1.5px] border-[#27a8ff] bg-transparent shadow-none"
|
||
style={{
|
||
left: localEditDraftRectStyle.left,
|
||
top: localEditDraftRectStyle.top,
|
||
width: Math.max(Number(localEditDraftRectStyle.width || 0), 1),
|
||
height: Math.max(Number(localEditDraftRectStyle.height || 0), 1),
|
||
}}
|
||
/>
|
||
) : null}
|
||
</>
|
||
)}
|
||
|
||
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
|
||
{isThisNodePicking && currentPickerClickStyle && (
|
||
<div
|
||
className="pointer-events-none absolute"
|
||
style={{
|
||
left: currentPickerClickStyle.left,
|
||
top: currentPickerClickStyle.top,
|
||
transform: "translate(-50%, -50%)",
|
||
}}
|
||
>
|
||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-dashed" />
|
||
</div>
|
||
)}
|
||
|
||
{/* 🆕 拾取模式:状态栏 */}
|
||
{isThisNodePicking && (
|
||
<div className="-translate-x-1/2 absolute bottom-2 left-1/2 flex items-center gap-2 rounded-full bg-white px-3 py-1.5 shadow-lg">
|
||
<Crosshair className="h-4 w-4 text-orange-500" />
|
||
<span className="font-medium text-xs">
|
||
{isPickerLoading ? "正在识别..." : "点击标注物品"}
|
||
</span>
|
||
<Button
|
||
className="h-6 w-6 rounded-full hover:bg-gray-100"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleCancelPicking();
|
||
}}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{hasCompareSource &&
|
||
!isThisNodePicking &&
|
||
!isCompareMode &&
|
||
!boxCutoutMode &&
|
||
!localEditMode &&
|
||
!cropPanelOpen &&
|
||
isSelected &&
|
||
!isMultiSelected && (
|
||
<button
|
||
aria-label={imageCompareItem.label}
|
||
className="-translate-y-1/2 pointer-events-auto absolute top-1/2 z-[220] inline-flex items-center justify-center rounded-full text-white shadow-[0_8px_16px_rgba(0,0,0,0.22)] transition-all"
|
||
data-canvas-no-drag="true"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setIsCompareMode((prev) => !prev);
|
||
}}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}}
|
||
style={{
|
||
width: `${compareButtonHitSize}px`,
|
||
height: `${compareButtonHitSize}px`,
|
||
right: `${compareButtonInset}px`,
|
||
}}
|
||
title={imageCompareItem.label}
|
||
type="button"
|
||
>
|
||
<span
|
||
className="inline-flex items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] transition-colors hover:bg-[#f7f9ff] hover:text-[#16181d]"
|
||
style={{
|
||
width: `${compareButtonVisualSize}px`,
|
||
height: `${compareButtonVisualSize}px`,
|
||
}}
|
||
>
|
||
<CanvasToolbarIcon
|
||
fallback={Scan}
|
||
item={imageCompareItem}
|
||
style={{
|
||
width: `${compareButtonIconSize}px`,
|
||
height: `${compareButtonIconSize}px`,
|
||
}}
|
||
/>
|
||
</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<ImageNodeEmptyState
|
||
detailProgressLabel={detailProgressLabel}
|
||
fileInputRef={fileInputRef}
|
||
generationProgress={generationProgress}
|
||
onImageUpload={handleImageUpload}
|
||
onSelectFile={handleSelectFile}
|
||
status={data.status}
|
||
/>
|
||
)}
|
||
|
||
{/* 处理中遮罩 */}
|
||
{isProcessing && (
|
||
<ImageNodeProcessingOverlay />
|
||
)}
|
||
|
||
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
||
|
||
{/* 悬浮时的更多操作按钮 (右上角) */}
|
||
{isHovered &&
|
||
data.src &&
|
||
!isProcessing &&
|
||
!isSelected &&
|
||
hasHoverImageMoreMenu ? (
|
||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
className="h-8 w-8 rounded-full border border-[#dbe2ee] bg-white text-[#5d687b] shadow-[0_0_0_1px_rgba(219,226,238,0.3)] hover:bg-[#f7f9ff] hover:text-[#16181d]"
|
||
size="icon"
|
||
title={imageNodeMoreItem.label}
|
||
variant="secondary"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="h-4 w-4"
|
||
fallback={MoreVertical}
|
||
item={imageNodeMoreItem}
|
||
/>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{imageRemoveBackgroundEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("rmbg")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Scissors}
|
||
item={imageRemoveBackgroundItem}
|
||
/>
|
||
<span>{imageRemoveBackgroundItem.label}</span>
|
||
<ImageActionCreditBadge
|
||
label={removeBackgroundCostShortLabel}
|
||
/>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageRemoveWatermarkEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Eraser}
|
||
item={imageRemoveWatermarkItem}
|
||
/>
|
||
<span>{imageRemoveWatermarkItem.label}</span>
|
||
<ImageActionCreditBadge
|
||
label={removeWatermarkCostShortLabel}
|
||
/>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageEnhanceEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("enhance")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Sparkles}
|
||
item={imageEnhanceItem}
|
||
/>
|
||
{imageEnhanceItem.label}
|
||
<ImageActionCreditBadge label={enhanceCostShortLabel} />
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageNoteEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("note")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={StickyNote}
|
||
item={imageNoteItem}
|
||
/>
|
||
{imageNoteItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isLayerSplitEnabled && imageLayerSplitEnabledByToolbar ? (
|
||
<DropdownMenuItem onClick={() => handleAction("layer-split")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Layers}
|
||
item={imageLayerSplitItem}
|
||
/>
|
||
{imageLayerSplitItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageResizeEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("resize")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Maximize2}
|
||
item={imageResizeItem}
|
||
/>
|
||
{imageResizeItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageMultiAngleEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("angle")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Box}
|
||
item={imageMultiAngleItem}
|
||
/>
|
||
<span>{imageMultiAngleItem.label}</span>
|
||
<ImageActionCreditBadge
|
||
label={angleEditCostShortLabel}
|
||
/>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imagePromptRegenerateEnabled ? (
|
||
<DropdownMenuItem
|
||
onClick={() => handleAction("prompt-regenerate")}
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={RefreshCw}
|
||
item={imagePromptRegenerateItem}
|
||
/>
|
||
<span>{imagePromptRegenerateItem.label}</span>
|
||
<ImageActionCreditBadge
|
||
label={promptRegenerateCostShortLabel}
|
||
/>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageTextEditEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("text-edit")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Type}
|
||
item={imageTextEditItem}
|
||
/>
|
||
{imageTextEditItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageExtendEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Expand}
|
||
item={imageExtendItem}
|
||
/>
|
||
<span>{imageExtendItem.label}</span>
|
||
<ImageActionCreditBadge label={extendCostShortLabel} />
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageCropEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("crop")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Crop}
|
||
item={imageCropItem}
|
||
/>
|
||
{imageCropItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageAdjustEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("adjust")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={SlidersHorizontal}
|
||
item={imageAdjustItem}
|
||
/>
|
||
{imageAdjustItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageFlipRotateEnabled ? (
|
||
<DropdownMenuItem onClick={() => handleAction("flip-rotate")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={FlipHorizontal2}
|
||
item={imageFlipRotateItem}
|
||
/>
|
||
{imageFlipRotateItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{imageRenameEnabled ? (
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
setDraftTitle(displayTitle);
|
||
setIsEditingTitle(true);
|
||
}}
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Pencil}
|
||
item={imageRenameItem}
|
||
/>
|
||
{imageRenameItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{hasHoverImageMorePrimaryActions &&
|
||
hasHoverImageMoreSecondaryActions ? (
|
||
<DropdownMenuSeparator />
|
||
) : null}
|
||
{imageDownloadEnabled ? (
|
||
<DropdownMenuItem 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}
|
||
|
||
{/* 提示词重绘对话框 */}
|
||
<PromptRegenerateDialog
|
||
isProcessing={isProcessing}
|
||
nodeId={node.id}
|
||
onConfirm={handlePromptRegenerate}
|
||
onOpenChange={setPromptDialogOpen}
|
||
onPromptChange={setRegeneratePrompt}
|
||
open={promptDialogOpen}
|
||
prompt={regeneratePrompt}
|
||
/>
|
||
|
||
<ThreeDGenerationDialog
|
||
costLabel={
|
||
threedGenerationDialogMode === "world"
|
||
? worldImageCostLabel
|
||
: tripoImageToModelCostLabel
|
||
}
|
||
costShortLabel={
|
||
threedGenerationDialogMode === "world"
|
||
? worldImageCostShortLabel
|
||
: tripoImageToModelCostShortLabel
|
||
}
|
||
isProcessing={isProcessing}
|
||
mode={threedGenerationDialogMode}
|
||
onConfirm={handleConfirmThreedGeneration}
|
||
onModeChange={setThreedGenerationDialogMode}
|
||
onTripoModelChange={setSelectedTripoModelVersion}
|
||
onWorldModelChange={setSelectedWorldModelVersion}
|
||
selectedTripoModelEntry={selectedTripoModelEntry}
|
||
selectedTripoModelVersion={selectedTripoModelVersion}
|
||
selectedWorldModelEntry={selectedWorldModelEntry}
|
||
selectedWorldModelVersion={selectedWorldModelVersion}
|
||
tripoModelOptions={tripoModelOptions}
|
||
worldModelOptions={worldModelOptions}
|
||
/>
|
||
|
||
{localEditMode &&
|
||
!isMultiSelected &&
|
||
data.src &&
|
||
!isThisNodePicking &&
|
||
!isCompareMode &&
|
||
typeof document !== "undefined" &&
|
||
createPortal(
|
||
<div
|
||
className="fixed z-[9999] flex items-center gap-2 rounded-[24px] border border-slate-200 bg-white px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] dark:border-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
|
||
)}
|
||
|
||
{aiWashQuickPanelOpen &&
|
||
imageAiWashQuickEnabled &&
|
||
isSelected &&
|
||
!isMultiSelected &&
|
||
data.src &&
|
||
!isEditingTitle &&
|
||
!sceneFusionDialogOpen &&
|
||
!promptDialogOpen &&
|
||
!threedGenerationDialogMode &&
|
||
!notePanelOpen &&
|
||
!boxCutoutMode &&
|
||
!localEditMode &&
|
||
!cropPanelOpen &&
|
||
typeof document !== "undefined" &&
|
||
createPortal(
|
||
<div
|
||
className="fixed z-[10000] overflow-hidden rounded-[14px] border border-slate-200 bg-white text-slate-950 shadow-[0_18px_44px_rgba(15,23,42,0.18)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
|
||
data-canvas-no-drag="true"
|
||
onClick={(event) => event.stopPropagation()}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
style={{
|
||
top: aiWashQuickPanelPosition.top,
|
||
left: aiWashQuickPanelPosition.left,
|
||
width: aiWashQuickPanelPosition.width,
|
||
maxHeight: aiWashQuickPanelPosition.maxHeight,
|
||
overflowY: "auto",
|
||
scrollbarWidth: "none",
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3 dark:border-white/10">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
||
<CanvasToolbarIcon
|
||
className="h-5 w-5"
|
||
fallback={Sparkles}
|
||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||
/>
|
||
</span>
|
||
<div className="min-w-0 truncate text-[17px] font-semibold tracking-[-0.02em]">
|
||
{aiWashQuickModeLabel}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
|
||
onClick={() => setAiWashQuickPanelOpen(false)}
|
||
title="关闭"
|
||
type="button"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
{aiWashConfigLoading ? (
|
||
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 text-[13px] text-slate-500 dark:border-white/10 dark:text-white/58">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
正在加载 AI洗图 配置...
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="divide-y divide-slate-100 dark:divide-white/10">
|
||
<div className="px-4 py-3">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||
<SlidersHorizontal className="h-4 w-4" />
|
||
目标
|
||
</div>
|
||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
||
{getAiWashStatusLabel(aiWashQuickStatus)}
|
||
</span>
|
||
</div>
|
||
<Select
|
||
onValueChange={(value) => {
|
||
setAiWashQuickPresetId(value);
|
||
setAiWashQuickStatus("idle");
|
||
}}
|
||
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
|
||
>
|
||
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||
<SelectValue placeholder="选择渲染目标" />
|
||
</SelectTrigger>
|
||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||
{aiWashQuickPresets.map((preset) => (
|
||
<SelectItem key={preset.id} value={preset.id}>
|
||
{preset.title}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{selectedAiWashQuickPreset?.description ? (
|
||
<div className="mt-2 line-clamp-2 text-[12px] leading-5 text-slate-500 dark:text-white/46">
|
||
{selectedAiWashQuickPreset.description}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="px-4 py-3">
|
||
<div className="mb-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||
参数
|
||
</div>
|
||
{shouldShowAiWashQuickModelSelector ? (
|
||
<div className="mb-3 space-y-1.5">
|
||
<Label className="text-[12px] font-medium text-slate-500 dark:text-white/54">
|
||
生图模型
|
||
</Label>
|
||
<Select
|
||
onValueChange={setAiWashQuickModelId}
|
||
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
||
>
|
||
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||
<SelectValue placeholder="选择生图模型" />
|
||
</SelectTrigger>
|
||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||
{aiWashQuickSelectableModels.map((model) => (
|
||
<SelectItem
|
||
disabled={model.selectable === false}
|
||
key={model.id}
|
||
value={model.id}
|
||
>
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
<span className="min-w-0 flex-1 truncate">
|
||
{model.displayName}
|
||
</span>
|
||
<span className="shrink-0 text-slate-500 text-xs dark:text-white/52">
|
||
{model.unitCost > 0
|
||
? `${model.unitCost} 积分/张`
|
||
: "未计费"}
|
||
</span>
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-[12px] leading-5 text-slate-500 dark:text-white/50">
|
||
{selectedAiWashQuickModel
|
||
? `${selectedAiWashQuickModel.displayName} · ${
|
||
selectedAiWashQuickModel.unitCost > 0
|
||
? `${selectedAiWashQuickModel.unitCost} 积分/张`
|
||
: "未设置积分"
|
||
}`
|
||
: aiWashModelOptionsLoading
|
||
? "正在加载可选模型..."
|
||
: "没有可选模型"}
|
||
</div>
|
||
</div>
|
||
) : selectedAiWashQuickModel ? (
|
||
<div className="mb-3 rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
|
||
{selectedAiWashQuickModel.displayName}
|
||
{selectedAiWashQuickModel.unitCost > 0
|
||
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
|
||
: ""}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="-mx-4 divide-y divide-slate-100 dark:divide-white/10">
|
||
{aiWashQuickControls.map((control) => {
|
||
const selectedIndex =
|
||
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
||
const activeLevel =
|
||
getAiWashControlLevelByIndex(control, selectedIndex) ||
|
||
control.levels[0] ||
|
||
null;
|
||
return (
|
||
<div
|
||
className="px-4 py-3"
|
||
key={control.id}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="text-[14px] font-semibold">
|
||
{control.label}
|
||
</div>
|
||
<span className="min-w-8 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[12px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
||
{activeLevel?.label || selectedIndex + 1}
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
className="w-full"
|
||
max={Math.max(0, control.levels.length - 1)}
|
||
min={0}
|
||
onValueChange={([next]) =>
|
||
setAiWashQuickLevels((current) => ({
|
||
...current,
|
||
[control.id]: Math.round(next ?? 0),
|
||
}))
|
||
}
|
||
rangeClassName="bg-slate-950 dark:bg-white"
|
||
step={1}
|
||
thumbClassName="h-3.5 w-3.5 border border-slate-300 bg-white shadow-[0_0_0_4px_rgba(15,23,42,0.06)]"
|
||
trackClassName="h-1.5 bg-slate-200 dark:bg-white/14"
|
||
value={[selectedIndex]}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-3 py-3">
|
||
<Button
|
||
className="h-10 w-full rounded-[10px] bg-slate-950 text-[14px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
||
disabled={
|
||
isSubmittingAiWashQuick ||
|
||
!selectedAiWashQuickPreset ||
|
||
!effectiveAiWashQuickModelRef
|
||
}
|
||
onClick={() => void handleSubmitAiWashQuick()}
|
||
type="button"
|
||
>
|
||
{isSubmittingAiWashQuick ? (
|
||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-3.5 w-3.5" />
|
||
)}
|
||
{isSubmittingAiWashQuick
|
||
? "生成中"
|
||
: `${aiWashQuickModeLabel}${
|
||
selectedAiWashQuickModel?.unitCost
|
||
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
||
: ""
|
||
}`}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
|
||
{isSelected &&
|
||
!isMultiSelected &&
|
||
data.src &&
|
||
!isEditingTitle &&
|
||
!sceneFusionDialogOpen &&
|
||
!promptDialogOpen &&
|
||
!threedGenerationDialogMode &&
|
||
!notePanelOpen &&
|
||
!boxCutoutMode &&
|
||
!localEditMode &&
|
||
!cropPanelOpen &&
|
||
typeof document !== "undefined" &&
|
||
createPortal(
|
||
<TooltipProvider delayDuration={200}>
|
||
<div
|
||
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-slate-200 bg-white px-2 py-1.5 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: "visible",
|
||
scrollbarWidth: "none",
|
||
}}
|
||
>
|
||
{imageAddToChatEnabled ? (
|
||
<Button
|
||
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||
disabled={isProcessing || isAddingToChat}
|
||
onClick={handleAddToChat}
|
||
title={imageAddToChatItem.label}
|
||
variant="ghost"
|
||
>
|
||
{isAddingToChat ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<CanvasToolbarIcon
|
||
active
|
||
className="h-4 w-4 dark:invert-0"
|
||
fallback={MessageSquarePlus}
|
||
item={imageAddToChatItem}
|
||
/>
|
||
)}
|
||
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
|
||
</Button>
|
||
) : null}
|
||
|
||
{imageAddToChatEnabled &&
|
||
(hasSelectedImagePrimaryToolbarActions ||
|
||
hasSelectedImageSecondaryToolbarActions) ? (
|
||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("pickObject") ? (
|
||
<Button
|
||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
||
onClick={handleStartPicking}
|
||
title={imagePickObjectItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Crosshair}
|
||
item={imagePickObjectItem}
|
||
/>
|
||
{imagePickObjectItem.label}
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("enhance") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("enhance")}
|
||
title={imageEnhanceItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Sparkles}
|
||
item={imageEnhanceItem}
|
||
/>
|
||
{imageEnhanceItem.label}
|
||
<ImageActionCreditBadge compact label={enhanceCostShortLabel} />
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("removeBackground") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("rmbg")}
|
||
title={imageRemoveBackgroundItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Scissors}
|
||
item={imageRemoveBackgroundItem}
|
||
/>
|
||
{imageRemoveBackgroundItem.label}
|
||
<ImageActionCreditBadge
|
||
compact
|
||
label={removeBackgroundCostShortLabel}
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("aiWashQuick") ? (
|
||
<Button
|
||
className={cn(
|
||
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
||
aiWashQuickPanelOpen
|
||
? "bg-slate-950 text-white hover:bg-slate-900 hover:text-white dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100"
|
||
: "text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
)}
|
||
disabled={isProcessing || isSubmittingAiWashQuick}
|
||
onClick={() => handleAction("ai-wash")}
|
||
title={aiWashQuickModeLabel}
|
||
variant="ghost"
|
||
>
|
||
{isSubmittingAiWashQuick ? (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<CanvasToolbarIcon
|
||
active={aiWashQuickPanelOpen}
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Sparkles}
|
||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||
/>
|
||
)}
|
||
{aiWashQuickModeLabel}
|
||
<ImageActionCreditBadge
|
||
compact
|
||
label={aiWashQuickCostShortLabel}
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("textEdit") ? (
|
||
<Button
|
||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
||
onClick={() => handleAction("text-edit")}
|
||
title={imageTextEditItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Type}
|
||
item={imageTextEditItem}
|
||
/>
|
||
{imageTextEditItem.label}
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("boxCutout") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("box-cutout")}
|
||
title={imageBoxCutoutItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Scan}
|
||
item={imageBoxCutoutItem}
|
||
/>
|
||
{imageBoxCutoutItem.label}
|
||
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("localEdit") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing || isSubmittingLocalEdit}
|
||
onClick={() => handleAction("local-edit")}
|
||
title={imageLocalEditItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Wand2}
|
||
item={imageLocalEditItem}
|
||
/>
|
||
{imageLocalEditItem.label}
|
||
<ImageActionCreditBadge
|
||
compact
|
||
label={localEditCostShortLabel}
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("removeWatermark") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("watermark")}
|
||
title={imageRemoveWatermarkItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Eraser}
|
||
item={imageRemoveWatermarkItem}
|
||
/>
|
||
{imageRemoveWatermarkItem.label}
|
||
<ImageActionCreditBadge
|
||
compact
|
||
label={removeWatermarkCostShortLabel}
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("resize") ? (
|
||
<Button
|
||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("resize")}
|
||
title={imageResizeItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Maximize2}
|
||
item={imageResizeItem}
|
||
/>
|
||
{imageResizeItem.label}
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("multiAngle") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("angle")}
|
||
title={imageMultiAngleItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Box}
|
||
item={imageMultiAngleItem}
|
||
/>
|
||
{imageMultiAngleItem.label}
|
||
<ImageActionCreditBadge
|
||
compact
|
||
label={angleEditCostShortLabel}
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("generate3dModel") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("generate-3d-model")}
|
||
title={imageGenerate3dModelItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Box}
|
||
item={imageGenerate3dModelItem}
|
||
/>
|
||
{imageGenerate3dModelItem.label}
|
||
<ImageActionCreditBadge
|
||
compact
|
||
label={tripoImageToModelCostShortLabel}
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("generate3dWorld") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("generate-3d-world")}
|
||
title={imageGenerate3dWorldItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Globe2}
|
||
item={imageGenerate3dWorldItem}
|
||
/>
|
||
{imageGenerate3dWorldItem.label}
|
||
<ImageActionCreditBadge
|
||
compact
|
||
label={worldImageCostShortLabel}
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("layerSplit") ? (
|
||
<Button
|
||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("layer-split")}
|
||
title={imageLayerSplitItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Layers}
|
||
item={imageLayerSplitItem}
|
||
/>
|
||
{imageLayerSplitItem.label}
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("extend") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("extend")}
|
||
title={imageExtendItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Expand}
|
||
item={imageExtendItem}
|
||
/>
|
||
{imageExtendItem.label}
|
||
<ImageActionCreditBadge compact label={extendCostShortLabel} />
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("crop") ? (
|
||
<Button
|
||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("crop")}
|
||
title={imageCropItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={Crop}
|
||
item={imageCropItem}
|
||
/>
|
||
{imageCropItem.label}
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("adjust") ? (
|
||
<Button
|
||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("adjust")}
|
||
title={imageAdjustItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={SlidersHorizontal}
|
||
item={imageAdjustItem}
|
||
/>
|
||
{imageAdjustItem.label}
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("flipRotate") ? (
|
||
<Button
|
||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("flip-rotate")}
|
||
title={imageFlipRotateItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={FlipHorizontal2}
|
||
item={imageFlipRotateItem}
|
||
/>
|
||
{imageFlipRotateItem.label}
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("promptRegenerate") ? (
|
||
<Button
|
||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("prompt-regenerate")}
|
||
title={imagePromptRegenerateItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={RefreshCw}
|
||
item={imagePromptRegenerateItem}
|
||
/>
|
||
{imagePromptRegenerateItem.label}
|
||
<ImageActionCreditBadge
|
||
compact
|
||
label={promptRegenerateCostShortLabel}
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
|
||
{isSelectedImageActionInline("note") ? (
|
||
<Button
|
||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||
disabled={isProcessing}
|
||
onClick={() => handleAction("note")}
|
||
title={imageNoteItem.label}
|
||
variant="ghost"
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-1.5 h-4 w-4"
|
||
fallback={StickyNote}
|
||
item={imageNoteItem}
|
||
/>
|
||
{imageNoteItem.label}
|
||
</Button>
|
||
) : null}
|
||
|
||
{hasSelectedImagePrimaryToolbarActions &&
|
||
hasSelectedImageSecondaryToolbarActions ? (
|
||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-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"
|
||
>
|
||
{isSelectedImageActionInMore("boxCutout") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("box-cutout")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Scan}
|
||
item={imageBoxCutoutItem}
|
||
/>
|
||
<span>{imageBoxCutoutItem.label}</span>
|
||
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("localEdit") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("local-edit")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Wand2}
|
||
item={imageLocalEditItem}
|
||
/>
|
||
<span>{imageLocalEditItem.label}</span>
|
||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("removeWatermark") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Eraser}
|
||
item={imageRemoveWatermarkItem}
|
||
/>
|
||
<span>{imageRemoveWatermarkItem.label}</span>
|
||
<ImageActionCreditBadge
|
||
label={removeWatermarkCostShortLabel}
|
||
/>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("resize") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("resize")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Maximize2}
|
||
item={imageResizeItem}
|
||
/>
|
||
{imageResizeItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("multiAngle") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("angle")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Box}
|
||
item={imageMultiAngleItem}
|
||
/>
|
||
<span>{imageMultiAngleItem.label}</span>
|
||
<ImageActionCreditBadge
|
||
label={angleEditCostShortLabel}
|
||
/>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("generate3dModel") ? (
|
||
<DropdownMenuItem
|
||
onClick={() => handleAction("generate-3d-model")}
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Box}
|
||
item={imageGenerate3dModelItem}
|
||
/>
|
||
<span>{imageGenerate3dModelItem.label}</span>
|
||
<ImageActionCreditBadge
|
||
label={tripoImageToModelCostShortLabel}
|
||
/>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("generate3dWorld") ? (
|
||
<DropdownMenuItem
|
||
onClick={() => handleAction("generate-3d-world")}
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Globe2}
|
||
item={imageGenerate3dWorldItem}
|
||
/>
|
||
<span>{imageGenerate3dWorldItem.label}</span>
|
||
<ImageActionCreditBadge label={worldImageCostShortLabel} />
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("layerSplit") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("layer-split")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Layers}
|
||
item={imageLayerSplitItem}
|
||
/>
|
||
{imageLayerSplitItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("extend") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Expand}
|
||
item={imageExtendItem}
|
||
/>
|
||
<span>{imageExtendItem.label}</span>
|
||
<ImageActionCreditBadge label={extendCostShortLabel} />
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("crop") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("crop")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={Crop}
|
||
item={imageCropItem}
|
||
/>
|
||
{imageCropItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("adjust") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("adjust")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={SlidersHorizontal}
|
||
item={imageAdjustItem}
|
||
/>
|
||
{imageAdjustItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("flipRotate") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("flip-rotate")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={FlipHorizontal2}
|
||
item={imageFlipRotateItem}
|
||
/>
|
||
{imageFlipRotateItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("promptRegenerate") ? (
|
||
<DropdownMenuItem
|
||
onClick={() => handleAction("prompt-regenerate")}
|
||
>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={RefreshCw}
|
||
item={imagePromptRegenerateItem}
|
||
/>
|
||
<span>{imagePromptRegenerateItem.label}</span>
|
||
<ImageActionCreditBadge
|
||
label={promptRegenerateCostShortLabel}
|
||
/>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{isSelectedImageActionInMore("note") ? (
|
||
<DropdownMenuItem onClick={() => handleAction("note")}>
|
||
<CanvasToolbarIcon
|
||
className="mr-2 h-4 w-4"
|
||
fallback={StickyNote}
|
||
item={imageNoteItem}
|
||
/>
|
||
{imageNoteItem.label}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{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
|
||
)}
|
||
|
||
<ImageAdjustFloatingPanel
|
||
draft={imageAdjustmentsDraft}
|
||
hasImageSource={Boolean(data.src)}
|
||
isSelected={isSelected}
|
||
label={imageAdjustItem.label}
|
||
onClose={() => setImageAdjustPanelOpen(false)}
|
||
onDraftChange={setImageAdjustmentsDraft}
|
||
onReset={handleResetImageAdjustments}
|
||
onTabChange={setImageAdjustTab}
|
||
open={imageAdjustPanelOpen}
|
||
position={imageAdjustPanelPosition}
|
||
scale={IMAGE_EDIT_PANEL_SCALE}
|
||
tab={imageAdjustTab}
|
||
/>
|
||
|
||
{isSelected &&
|
||
flipRotatePanelOpen &&
|
||
data.src &&
|
||
typeof document !== "undefined" &&
|
||
createPortal(
|
||
<TooltipProvider delayDuration={120}>
|
||
<div
|
||
className="fixed z-[9999] flex max-w-[calc(100vw-24px)] items-center overflow-hidden rounded-[26px] border border-[#ece8e1] bg-white shadow-[0_18px_42px_rgba(15,23,42,0.14)]"
|
||
onClick={(event) => event.stopPropagation()}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
style={{
|
||
top: flipRotatePanelPosition.top,
|
||
left: flipRotatePanelPosition.left,
|
||
maxWidth: `${Math.floor(
|
||
flipRotatePanelPosition.maxWidth / IMAGE_EDIT_PANEL_SCALE
|
||
)}px`,
|
||
transform: `scale(${IMAGE_EDIT_PANEL_SCALE})`,
|
||
transformOrigin: "top left",
|
||
}}
|
||
>
|
||
<div className="flex h-[82px] shrink-0 items-center gap-3 px-7">
|
||
<FlipHorizontal2 className="h-5 w-5 text-[#2d3136]" />
|
||
<span className="whitespace-nowrap text-[18px] font-semibold tracking-[0] text-[#22252b]">
|
||
{imageFlipRotateItem.label}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="h-[82px] w-px shrink-0 bg-[#ece8e1]" />
|
||
|
||
<div className="flex h-[82px] min-w-[138px] shrink-0 items-center gap-3 px-7">
|
||
<RotateCcw className="h-5 w-5 text-[#4f5561]" />
|
||
<span className="text-[18px] font-medium tabular-nums tracking-[0] text-[#23262d]">
|
||
0°
|
||
</span>
|
||
</div>
|
||
|
||
<div className="h-[82px] w-px shrink-0 bg-[#ece8e1]" />
|
||
|
||
<div className="flex items-center gap-2 px-3 py-2">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
className="inline-flex h-14 w-14 items-center justify-center rounded-[18px] text-[#2b2f36] transition-colors hover:bg-[#f3f1ec] disabled:cursor-not-allowed disabled:text-[#b9b5ae]"
|
||
disabled={isApplyingFlipRotate || !hasFlipRotateReset}
|
||
onClick={() => void handleApplyFlipRotate("reset")}
|
||
type="button"
|
||
>
|
||
{isApplyingFlipRotate ? (
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
) : (
|
||
<RotateCcw className="h-5 w-5" />
|
||
)}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>恢复原图</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
className="inline-flex h-14 w-14 items-center justify-center rounded-[18px] text-[#2b2f36] transition-colors hover:bg-[#f3f1ec] disabled:cursor-not-allowed disabled:text-[#b9b5ae]"
|
||
disabled={isApplyingFlipRotate}
|
||
onClick={() => void handleApplyFlipRotate("horizontal")}
|
||
type="button"
|
||
>
|
||
{isApplyingFlipRotate ? (
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
) : (
|
||
<FlipHorizontal2 className="h-5 w-5" />
|
||
)}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>水平翻转</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
className="inline-flex h-14 w-14 items-center justify-center rounded-[18px] text-[#2b2f36] transition-colors hover:bg-[#f3f1ec] disabled:cursor-not-allowed disabled:text-[#b9b5ae]"
|
||
disabled={isApplyingFlipRotate}
|
||
onClick={() => void handleApplyFlipRotate("vertical")}
|
||
type="button"
|
||
>
|
||
{isApplyingFlipRotate ? (
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
) : (
|
||
<FlipVertical2 className="h-5 w-5" />
|
||
)}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>垂直翻转</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
className="inline-flex h-14 w-14 items-center justify-center rounded-[18px] text-[#2b2f36] transition-colors hover:bg-[#f3f1ec] disabled:cursor-not-allowed disabled:text-[#b9b5ae]"
|
||
disabled={isApplyingFlipRotate}
|
||
onClick={() => setFlipRotatePanelOpen(false)}
|
||
type="button"
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>关闭</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
</TooltipProvider>,
|
||
document.body
|
||
)}
|
||
|
||
{isSelected &&
|
||
anglePanelOpen &&
|
||
data.src &&
|
||
typeof document !== "undefined" &&
|
||
createPortal(
|
||
<div
|
||
className="fixed z-[9999] w-[520px] rounded-[28px] border border-[#ececef] bg-white p-6 shadow-[0_24px_64px_rgba(15,23,42,0.12)]"
|
||
onClick={(event) => event.stopPropagation()}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
style={{
|
||
top: anglePanelPosition.top,
|
||
left: anglePanelPosition.left,
|
||
transform: `scale(${ANGLE_PANEL_SCALE})`,
|
||
transformOrigin: "top left",
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-[20px] font-semibold tracking-[0] text-[#17181c]">
|
||
多角度
|
||
</h3>
|
||
<button
|
||
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-[#1f2024] transition-colors hover:bg-[#f5f6f8]"
|
||
disabled={isApplyingAngleEdit}
|
||
onClick={handleResetAnglePanel}
|
||
type="button"
|
||
>
|
||
<RefreshCw className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-5 rounded-[18px] border border-[#ededf0] bg-[#f5f5f6] p-1">
|
||
<div className="grid grid-cols-2 gap-1">
|
||
{(
|
||
[
|
||
{ id: "subject", label: "主体" },
|
||
{ id: "camera", label: "摄像头" },
|
||
] as const
|
||
).map((item) => {
|
||
const active = anglePanelMode === item.id;
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"h-[52px] rounded-[16px] text-[16px] font-semibold tracking-[0] transition-all",
|
||
active
|
||
? "bg-white text-[#1f2023] shadow-[0_1px_2px_rgba(16,24,40,0.06)]"
|
||
: "text-[#767982] hover:text-[#2d3036]"
|
||
)}
|
||
disabled={isApplyingAngleEdit}
|
||
key={item.id}
|
||
onClick={() => setAnglePanelMode(item.id)}
|
||
type="button"
|
||
>
|
||
{item.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative mt-4 overflow-hidden rounded-[26px] border border-[#efeff2] bg-[#fcfcfd] px-4 py-4">
|
||
<div
|
||
className={cn(
|
||
"relative flex h-[332px] items-center justify-center overflow-hidden rounded-[22px] [perspective:1200px]",
|
||
isApplyingAngleEdit
|
||
? "cursor-default"
|
||
: isAnglePreviewDragging
|
||
? "cursor-grabbing"
|
||
: "cursor-grab"
|
||
)}
|
||
onLostPointerCapture={handleAnglePreviewPointerEnd}
|
||
onPointerCancel={handleAnglePreviewPointerEnd}
|
||
onPointerDown={handleAnglePreviewPointerDown}
|
||
onPointerMove={handleAnglePreviewPointerMove}
|
||
onPointerUp={handleAnglePreviewPointerEnd}
|
||
style={{ touchAction: "none" }}
|
||
>
|
||
<div
|
||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||
style={angleOrbitWireframeStyle}
|
||
>
|
||
<div
|
||
className="relative shrink-0 [transform-style:preserve-3d]"
|
||
style={{
|
||
width: `${ANGLE_PANEL_WIREFRAME_DIAMETER}px`,
|
||
height: `${ANGLE_PANEL_WIREFRAME_DIAMETER}px`,
|
||
}}
|
||
>
|
||
<div className="absolute inset-0 rounded-full border border-[#e4e8ef]/90" />
|
||
|
||
{ANGLE_PREVIEW_VERTICAL_RING_ROTATIONS.map((rotation, index) => (
|
||
<div
|
||
className="absolute inset-0 rounded-full border border-[#e7ebf2]"
|
||
key={`angle-vertical-ring-${rotation}`}
|
||
style={{
|
||
backfaceVisibility: "hidden",
|
||
opacity: 0.9 - index * 0.026,
|
||
transform: `rotateY(${rotation}deg)`,
|
||
transformStyle: "preserve-3d",
|
||
}}
|
||
/>
|
||
))}
|
||
|
||
{ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS.map((rotation, index) => (
|
||
<div
|
||
className="absolute inset-0 rounded-full border border-[#e6eaf1]"
|
||
key={`angle-horizontal-ring-${rotation}`}
|
||
style={{
|
||
backfaceVisibility: "hidden",
|
||
opacity: rotation === 0 ? 0.94 : 0.82 - index * 0.045,
|
||
transform: `rotateX(${rotation}deg)`,
|
||
transformStyle: "preserve-3d",
|
||
}}
|
||
/>
|
||
))}
|
||
|
||
{ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS.map((rotation, index) => (
|
||
<Fragment key={`angle-diagonal-ring-${rotation}`}>
|
||
<div
|
||
className="absolute inset-0 rounded-full border border-[#edf0f5]"
|
||
style={{
|
||
backfaceVisibility: "hidden",
|
||
opacity: 0.54 - index * 0.035,
|
||
transform: `rotateX(67deg) rotateY(${rotation}deg)`,
|
||
transformStyle: "preserve-3d",
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute inset-0 rounded-full border border-[#edf0f5]"
|
||
style={{
|
||
backfaceVisibility: "hidden",
|
||
opacity: 0.5 - index * 0.032,
|
||
transform: `rotateX(-67deg) rotateY(${rotation + 12}deg)`,
|
||
transformStyle: "preserve-3d",
|
||
}}
|
||
/>
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="absolute top-[24px] left-1/2 z-[3] inline-flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full text-[#a0a3ab] transition-colors hover:bg-white hover:text-[#30333a]"
|
||
disabled={isApplyingAngleEdit}
|
||
onClick={() =>
|
||
setAngleVerticalAngle((prev) => clampNumber(prev + 5, -30, 60))
|
||
}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
type="button"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className="absolute bottom-[24px] left-1/2 z-[3] inline-flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full text-[#a0a3ab] transition-colors hover:bg-white hover:text-[#30333a]"
|
||
disabled={isApplyingAngleEdit}
|
||
onClick={() =>
|
||
setAngleVerticalAngle((prev) => clampNumber(prev - 5, -30, 60))
|
||
}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
type="button"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className="absolute top-1/2 left-[18px] z-[3] inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-[#a0a3ab] transition-colors hover:bg-white hover:text-[#30333a]"
|
||
disabled={isApplyingAngleEdit}
|
||
onClick={() =>
|
||
setAngleHorizontalAngle((prev) =>
|
||
clampNumber(prev - 10, -180, 180)
|
||
)
|
||
}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
type="button"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className="absolute top-1/2 right-[18px] z-[3] inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-[#a0a3ab] transition-colors hover:bg-white hover:text-[#30333a]"
|
||
disabled={isApplyingAngleEdit}
|
||
onClick={() =>
|
||
setAngleHorizontalAngle((prev) =>
|
||
clampNumber(prev + 10, -180, 180)
|
||
)
|
||
}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
type="button"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
|
||
{anglePanelMode === "subject" ? (
|
||
<div
|
||
className="relative z-[2]"
|
||
style={{
|
||
width: `${anglePreviewFrameSize.width}px`,
|
||
height: `${anglePreviewFrameSize.height}px`,
|
||
transform: `rotateX(${-angleVerticalAngle * 0.5}deg) rotateY(${angleHorizontalAngle * 0.68}deg) scale(${anglePreviewScale})`,
|
||
transformStyle: "preserve-3d",
|
||
transition: isAnglePreviewDragging
|
||
? "none"
|
||
: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1)",
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute inset-[10%] rounded-full bg-black/12 blur-[20px]"
|
||
style={{
|
||
transform: `translate3d(0, ${anglePreviewFrameSize.height * 0.31}px, -18px) scale(${0.96 + angleZoom / 240})`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute inset-0 rounded-[26px] border border-[#eef0f4] bg-white shadow-[0_16px_34px_rgba(15,23,42,0.08)]"
|
||
style={{
|
||
transform: `translateZ(${anglePreviewDepth / 2}px)`,
|
||
}}
|
||
>
|
||
<div className="absolute inset-[6px] overflow-hidden rounded-[20px] bg-[#f9fafc]">
|
||
<img
|
||
alt={displayTitle}
|
||
className="h-full w-full object-contain"
|
||
draggable={false}
|
||
src={anglePreviewImageSrc}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="absolute inset-0 rounded-[26px] border border-[#e5e8ef] bg-[linear-gradient(180deg,#f7f8fb,#e6e9f0)]"
|
||
style={{
|
||
transform: `translateZ(-${anglePreviewDepth / 2}px)`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute left-0 top-[10px] bottom-[10px] rounded-l-[18px] border border-[#dde1e9] bg-[linear-gradient(180deg,#eff2f7,#dde2ea)]"
|
||
style={{
|
||
width: `${anglePreviewDepth}px`,
|
||
transform: `translateX(-${anglePreviewDepth / 2}px) rotateY(-90deg)`,
|
||
transformOrigin: "center",
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute right-0 top-[10px] bottom-[10px] rounded-r-[18px] border border-[#dde1e9] bg-[linear-gradient(180deg,#eef1f6,#dde2ea)]"
|
||
style={{
|
||
width: `${anglePreviewDepth}px`,
|
||
transform: `translateX(${anglePreviewDepth / 2}px) rotateY(90deg)`,
|
||
transformOrigin: "center",
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute left-[10px] right-[10px] top-0 rounded-t-[16px] border border-[#eef0f4] bg-[linear-gradient(180deg,#ffffff,#edf0f5)]"
|
||
style={{
|
||
height: `${anglePreviewDepth}px`,
|
||
transform: `translateY(-${anglePreviewDepth / 2}px) rotateX(90deg)`,
|
||
transformOrigin: "center",
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute left-[10px] right-[10px] bottom-0 rounded-b-[16px] border border-[#dde1ea] bg-[linear-gradient(180deg,#e4e8ef,#d5dbe5)]"
|
||
style={{
|
||
height: `${anglePreviewDepth}px`,
|
||
transform: `translateY(${anglePreviewDepth / 2}px) rotateX(-90deg)`,
|
||
transformOrigin: "center",
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="relative z-[2]"
|
||
style={{
|
||
height: `${ANGLE_PANEL_CAMERA_SCENE_SIZE}px`,
|
||
transform: `scale(${angleCameraSceneScale})`,
|
||
transition: isAnglePreviewDragging
|
||
? "none"
|
||
: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1)",
|
||
width: `${ANGLE_PANEL_CAMERA_SCENE_SIZE}px`,
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute left-1/2 top-1/2 z-[2] h-[116px] w-[116px] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-[22px] border border-[#eceff4] bg-white/96 shadow-[0_12px_28px_rgba(15,23,42,0.08)]"
|
||
style={{
|
||
backdropFilter: "blur(8px)",
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute inset-[10px] rounded-[17px] border border-[#eef1f5]"
|
||
style={{
|
||
backgroundImage:
|
||
"linear-gradient(180deg,#fcfcfd,#f5f6f8), linear-gradient(90deg,rgba(227,230,236,0.55) 1px,transparent 1px), linear-gradient(rgba(227,230,236,0.55) 1px,transparent 1px)",
|
||
backgroundPosition: "0 0, 0 0, 0 0",
|
||
backgroundSize: "100% 100%, 14px 14px, 14px 14px",
|
||
backgroundBlendMode: "normal",
|
||
}}
|
||
>
|
||
<img
|
||
alt={displayTitle}
|
||
className="absolute left-1/2 top-1/2 max-h-[76px] max-w-[76px] -translate-x-1/2 -translate-y-1/2 object-contain"
|
||
draggable={false}
|
||
src={anglePreviewImageSrc}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className="absolute left-1/2 top-1/2 z-[1] h-px bg-[#dfe4ec]"
|
||
style={{
|
||
width: `${Math.max(40, angleCameraConnection.length - 12)}px`,
|
||
transform: `translateY(-0.5px) rotate(${angleCameraConnection.angle}deg)`,
|
||
transformOrigin: "0 50%",
|
||
}}
|
||
/>
|
||
|
||
<div
|
||
className="absolute left-1/2 top-1/2 z-[3]"
|
||
style={{
|
||
transform: `translate3d(${angleCameraOrbitPosition.x}px, ${angleCameraOrbitPosition.y}px, ${angleCameraOrbitPosition.z}px)`,
|
||
transformStyle: "preserve-3d",
|
||
transition: isAnglePreviewDragging
|
||
? "none"
|
||
: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1)",
|
||
}}
|
||
>
|
||
<div
|
||
className="relative"
|
||
style={{
|
||
width: "60px",
|
||
height: "60px",
|
||
transform:
|
||
"translate(-50%, -50%) rotateX(-18deg) rotateY(28deg)",
|
||
transformStyle: "preserve-3d",
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute inset-0 rounded-[15px] border border-[#e5e9f0] bg-[linear-gradient(180deg,#ffffff,#edf1f5)] shadow-[0_12px_24px_rgba(15,23,42,0.12)]"
|
||
style={{ transform: "translateZ(10px)" }}
|
||
>
|
||
<div className="absolute inset-[10px] rounded-[10px] border border-[#edf1f6] bg-[linear-gradient(180deg,#f9fafc,#eef1f5)]">
|
||
<AngleCameraModelGlyph />
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="absolute inset-y-[5px] -right-[11px] rounded-r-[12px] border border-[#dde2ea] bg-[linear-gradient(180deg,#eef2f7,#dfe4ec)]"
|
||
style={{
|
||
width: "22px",
|
||
transform: "rotateY(90deg)",
|
||
transformOrigin: "left center",
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute inset-x-[5px] -top-[11px] rounded-t-[12px] border border-[#e8ecf2] bg-[linear-gradient(180deg,#ffffff,#edf1f6)]"
|
||
style={{
|
||
height: "22px",
|
||
transform: "rotateX(90deg)",
|
||
transformOrigin: "center bottom",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{anglePanelMode === "subject" ? (
|
||
<div className="pointer-events-none absolute bottom-[36px] left-[34px] z-[3] flex h-[50px] w-[50px] items-center justify-center rounded-[16px] border border-[#eceef3] bg-white shadow-[0_10px_24px_rgba(15,23,42,0.07)]">
|
||
<Box className="h-5 w-5 text-[#8a8f98]" />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 space-y-4">
|
||
<AngleEditPanelSlider
|
||
label="旋转"
|
||
max={180}
|
||
min={-180}
|
||
onChange={setAngleHorizontalAngle}
|
||
value={angleHorizontalAngle}
|
||
valueLabel={String(Math.round(angleHorizontalAngle))}
|
||
/>
|
||
<AngleEditPanelSlider
|
||
label="倾斜"
|
||
max={60}
|
||
min={-30}
|
||
onChange={setAngleVerticalAngle}
|
||
value={angleVerticalAngle}
|
||
valueLabel={String(Math.round(angleVerticalAngle))}
|
||
/>
|
||
<AngleEditPanelSlider
|
||
label="缩放"
|
||
max={100}
|
||
min={0}
|
||
onChange={setAngleZoom}
|
||
value={angleZoom}
|
||
valueLabel={formatAnglePanelZoomLabel(angleZoom)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||
<button
|
||
className="inline-flex h-[50px] items-center justify-center rounded-[18px] border border-[#dfe2e8] bg-white text-[17px] font-medium text-[#27292f] transition-colors hover:bg-[#f7f8fa]"
|
||
disabled={isApplyingAngleEdit}
|
||
onClick={() => setAnglePanelOpen(false)}
|
||
type="button"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className="group relative inline-flex h-[50px] items-center justify-center gap-2 rounded-[18px] bg-[#2d2f34] text-[17px] font-semibold text-white transition-colors hover:bg-[#23252a] disabled:cursor-not-allowed disabled:bg-[#cfd2d8] disabled:text-white/80"
|
||
disabled={isApplyingAngleEdit}
|
||
onClick={() => void handleApplyAngleEdit()}
|
||
type="button"
|
||
>
|
||
{isApplyingAngleEdit ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
生成中
|
||
</>
|
||
) : (
|
||
<>
|
||
立即使用
|
||
<ImageActionCreditBadge
|
||
className="ml-0 bg-white/18 text-white"
|
||
compact
|
||
label={angleEditCostShortLabel}
|
||
/>
|
||
<Sparkles className="h-4 w-4" />
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
|
||
{/* 文字编辑面板(显示在图片节点旁边) */}
|
||
{isSelected &&
|
||
textEditPanelOpen &&
|
||
data.src &&
|
||
typeof document !== "undefined" &&
|
||
createPortal(
|
||
<div
|
||
className="fixed z-[9999] w-[360px] rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
style={{
|
||
top: getTextPanelPosition().top,
|
||
left: getTextPanelPosition().left,
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between border-border/70 border-b px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<Type className="h-4 w-4 text-primary" />
|
||
<span className="font-medium text-sm">编辑文字</span>
|
||
</div>
|
||
<Button
|
||
className="h-7 w-7"
|
||
onClick={() => setTextEditPanelOpen(false)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3">
|
||
<p className="text-muted-foreground text-xs">
|
||
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
|
||
</p>
|
||
|
||
{isDetectingText && (
|
||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在识别图片文字...
|
||
</div>
|
||
)}
|
||
|
||
{textEditError && !isDetectingText && (
|
||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-destructive text-xs">
|
||
{textEditError}
|
||
</div>
|
||
)}
|
||
|
||
{!isDetectingText &&
|
||
textEditItems.length === 0 &&
|
||
!textEditError && (
|
||
<div className="rounded-md bg-slate-50 px-3 py-3 text-muted-foreground text-xs dark:bg-zinc-900">
|
||
未识别到可编辑文字,可点击“重新识别”再试一次。
|
||
</div>
|
||
)}
|
||
|
||
{textEditItems.map((item, index) => (
|
||
<div
|
||
className="space-y-2 rounded-lg border border-border/70 p-3"
|
||
key={item.id}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="text-[11px] text-muted-foreground">
|
||
文本 {index + 1}
|
||
{typeof item.confidence === "number" &&
|
||
` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
|
||
</div>
|
||
<Button
|
||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||
onClick={() => handleRemoveTextItem(item.id)}
|
||
size="icon"
|
||
title="删除该条文字"
|
||
variant="ghost"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
<div className="line-clamp-2 text-muted-foreground text-xs">
|
||
原文:{item.originalText}
|
||
</div>
|
||
<Input
|
||
onChange={(e) =>
|
||
handleTextItemChange(item.id, e.target.value)
|
||
}
|
||
placeholder="输入替换后的文字"
|
||
value={item.editedText}
|
||
/>
|
||
<TextEditColorField
|
||
value={item.textColor}
|
||
onChange={(value) =>
|
||
handleTextItemColorChange(item.id, value)
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
{deletedTextItems.length > 0 && (
|
||
<div className="rounded-md border border-orange-300 border-dashed bg-orange-50 px-3 py-2 text-orange-700 text-xs dark:border-orange-900 dark:bg-orange-950/40 dark:text-orange-200">
|
||
已标记删除 {deletedTextItems.length}{" "}
|
||
条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-2 border-border/70 border-t px-4 py-3">
|
||
<Button
|
||
disabled={isDetectingText || isApplyingTextEdit}
|
||
onClick={() => detectTextItems(true)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
{isDetectingText ? (
|
||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||
)}
|
||
重新识别
|
||
</Button>
|
||
<Button
|
||
disabled={
|
||
isDetectingText ||
|
||
isApplyingTextEdit ||
|
||
!hasTextEditChanges
|
||
}
|
||
onClick={handleApplyTextEdit}
|
||
size="sm"
|
||
>
|
||
{isApplyingTextEdit ? (
|
||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Check className="mr-1 h-3.5 w-3.5" />
|
||
)}
|
||
{hasTextEditChanges ? "确认替换" : "先修改文字"}
|
||
</Button>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
|
||
{/* 图片标注器 */}
|
||
{annotatorOpen && data.src && (
|
||
<ImageAnnotator
|
||
imageUrl={data.src}
|
||
onCancel={() => setAnnotatorOpen(false)}
|
||
onConfirm={handleAnnotateComplete}
|
||
onOpenChange={setAnnotatorOpen}
|
||
open={annotatorOpen}
|
||
/>
|
||
)}
|
||
|
||
{resizeAnnotatorOpen && data.src && (
|
||
<ImageAnnotator
|
||
confirmButtonText="保存处理结果"
|
||
imageUrl={data.src}
|
||
initialResizeMode
|
||
key={`resize-${node.id}-${data.src}`}
|
||
onCancel={() => setResizeAnnotatorOpen(false)}
|
||
onConfirm={handleResizeEditorComplete}
|
||
onOpenChange={setResizeAnnotatorOpen}
|
||
open={resizeAnnotatorOpen}
|
||
/>
|
||
)}
|
||
|
||
{/* 双击放大预览弹窗 */}
|
||
{fullscreenPreviewOpen &&
|
||
data.src &&
|
||
createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
|
||
onClick={() => setFullscreenPreviewOpen(false)}
|
||
>
|
||
{/* 关闭按钮 */}
|
||
<button
|
||
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
||
onClick={() => setFullscreenPreviewOpen(false)}
|
||
>
|
||
<svg
|
||
className="h-6 w-6 text-white"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
d="M6 18L18 6M6 6l12 12"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
/>
|
||
</svg>
|
||
</button>
|
||
|
||
{/* 图片 */}
|
||
<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
|
||
)}
|
||
</>
|
||
);
|
||
}
|