refactor: split image node presentation sections

This commit is contained in:
Your Name 2026-05-26 21:43:58 +08:00
parent c79724eefa
commit 91a98083a1
5 changed files with 579 additions and 376 deletions

View File

@ -1,7 +1,6 @@
"use client";
import {
AlertCircle,
Blend,
Box,
Camera,
@ -51,6 +50,17 @@ import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecution
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
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 {
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";
@ -70,13 +80,6 @@ import {
} 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 {
@ -327,9 +330,6 @@ const ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS = [
];
const ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58];
const DEFAULT_CROP_PRESET_ID = "1:1";
const IMAGE_ADJUST_NOISE_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.18' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(#n)' opacity='1'/></svg>"
)}`;
const DEFAULT_IMAGE_ADJUSTMENTS: ImageAdjustmentState = {
light: 0,
exposure: 0,
@ -1772,12 +1772,11 @@ export function ImageNode({
);
const selectedTitleStyle = useMemo<React.CSSProperties>(
() => ({
top: `${-28 * nodeContentScale}px`,
transform: `scale(${nodeContentScale})`,
transformOrigin: "bottom left",
width: `${100 / nodeContentScale}%`,
fontSize: 12,
lineHeight: "16px",
top: -28,
}),
[nodeContentScale]
[]
);
const detailProgressLabel =
typeof data.metadata?.detailProgressLabel === "string"
@ -7582,107 +7581,12 @@ export function ImageNode({
}, [boxCutoutMetrics]);
const renderImageAdjustmentOverlays = useCallback(() => {
if (!hasActiveImageAdjustments) return null;
const temperatureOpacity = Math.min(
0.38,
Math.abs(activeImageAdjustments.temperature) / 260
);
const tintOpacity = Math.min(
0.28,
Math.abs(activeImageAdjustments.tint) / 300
);
const grainOpacity = Math.min(0.28, activeImageAdjustments.grain / 340);
const vignetteOpacity = Math.min(
0.42,
activeImageAdjustments.vignette / 180
);
const softLightOpacity = Math.min(
0.24,
activeImageAdjustments.softLight / 320
);
const glowOpacity = Math.min(0.22, activeImageAdjustments.glow / 280);
return (
<>
{temperatureOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
background:
activeImageAdjustments.temperature >= 0
? "linear-gradient(135deg, rgba(255,188,109,0.9), rgba(255,232,194,0.42))"
: "linear-gradient(135deg, rgba(109,180,255,0.86), rgba(219,240,255,0.36))",
mixBlendMode: "soft-light",
opacity: temperatureOpacity,
}}
/>
) : null}
{tintOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
background:
activeImageAdjustments.tint >= 0
? "linear-gradient(135deg, rgba(244,105,214,0.78), rgba(255,203,236,0.28))"
: "linear-gradient(135deg, rgba(76,214,141,0.76), rgba(194,255,221,0.28))",
mixBlendMode: "overlay",
opacity: tintOpacity,
}}
/>
) : null}
{softLightOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
background:
"linear-gradient(180deg, rgba(255,247,229,0.84) 0%, rgba(255,255,255,0.08) 34%, rgba(255,219,168,0.42) 100%)",
mixBlendMode: "soft-light",
opacity: softLightOpacity,
}}
/>
) : null}
{glowOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
boxShadow:
"inset 0 0 72px rgba(255,255,255,0.78), inset 0 0 24px rgba(255,236,201,0.66)",
mixBlendMode: "screen",
opacity: glowOpacity,
}}
/>
) : null}
{vignetteOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
background:
"radial-gradient(circle at center, rgba(0,0,0,0) 38%, rgba(0,0,0,0.06) 58%, rgba(0,0,0,0.34) 100%)",
mixBlendMode: "multiply",
opacity: vignetteOpacity,
}}
/>
) : null}
{grainOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...imageAdjustOverlayStyle,
backgroundImage: `url("${IMAGE_ADJUST_NOISE_DATA_URI}")`,
backgroundRepeat: "repeat",
backgroundSize: "140px 140px",
mixBlendMode: "overlay",
opacity: grainOpacity,
}}
/>
) : null}
</>
<ImageNodeAdjustOverlays
adjustments={activeImageAdjustments}
hasActiveAdjustments={hasActiveImageAdjustments}
style={imageAdjustOverlayStyle}
/>
);
}, [
activeImageAdjustments,
@ -7776,54 +7680,17 @@ export function ImageNode({
<>
{/* 选中时在图片上方显示标题 */}
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
<div
className="-top-7 absolute right-0 left-0 z-10 flex items-center justify-between px-1 text-[#2f7df6] text-xs"
<ImageNodeSelectedTitle
displayTitle={displayTitle}
draftTitle={draftTitle}
isEditingTitle={isEditingTitle}
onCancelEditing={() => setIsEditingTitle(false)}
onDraftTitleChange={setDraftTitle}
onSaveTitle={handleSaveTitle}
onStartEditing={() => setIsEditingTitle(true)}
resolutionLabel={resolutionLabel}
style={selectedTitleStyle}
>
<div className="flex min-w-0 items-center gap-1">
<ImagePlus className="h-3 w-3" />
{isEditingTitle ? (
<Input
autoFocus
className="h-6 w-[160px] px-2 py-0 text-xs"
data-canvas-no-drag="true"
onBlur={handleSaveTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveTitle();
} else if (e.key === "Escape") {
e.preventDefault();
setDraftTitle(displayTitle);
setIsEditingTitle(false);
}
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
value={draftTitle}
/>
) : (
<span
className="max-w-[150px] cursor-text truncate"
onDoubleClick={(e) => {
e.stopPropagation();
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
title="双击重命名"
>
{displayTitle}
</span>
)}
</div>
{resolutionLabel ? (
<span className="ml-2 shrink-0 tabular-nums tracking-wide">
{resolutionLabel}
</span>
) : null}
</div>
/>
)}
<div
@ -7843,12 +7710,7 @@ export function ImageNode({
{data.src ? (
<div className="relative h-full w-full">
{!shouldRequestDisplayImage ? (
<div className="flex h-full w-full items-center justify-center bg-slate-50 text-slate-400 text-xs dark:bg-zinc-900 dark:text-zinc-500">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>图片即将进入视口时加载</span>
</div>
</div>
<ImageNodeDeferredLoadState />
) : isCompareMode && hasCompareSource ? (
<div
className="relative h-full w-full cursor-col-resize select-none overflow-hidden bg-slate-50 dark:bg-zinc-900"
@ -8614,66 +8476,19 @@ export function ImageNode({
</button>
)}
</div>
) : data.status === "generating" ? (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="select-none px-3 text-sm font-medium text-[#3f4b63]">
{detailProgressLabel || "生成中..."}
</span>
<span className="mt-1 text-xs text-[#7a8395]">图片处理中</span>
</div>
) : data.status === "failed" ? (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
<AlertCircle className="h-5 w-5" />
</span>
<span className="select-none text-sm font-medium text-[#c84b5e]">
生成失败
</span>
<span className="mt-1 text-xs text-[#b77480]">请重新上传或再试一次</span>
</div>
) : (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<ImagePlus className="h-5 w-5" />
</span>
<span className="select-none text-sm font-medium text-[#3f4b63]">
上传图片
</span>
<span className="mb-4 mt-1 select-none text-xs text-[#7a8395]">
添加参考图、角色图或关键帧素材
</span>
{/* 隐藏的文件输入 */}
<input
accept="image/*"
className="hidden"
onChange={handleImageUpload}
ref={fileInputRef}
type="file"
/>
{/* 选择文件按钮 */}
<button
className="cursor-pointer rounded-[14px] border border-[#d7dff1] bg-white px-4 py-2 text-sm font-medium text-[#3954db] shadow-[0_0_0_1px_rgba(219,226,238,0.24)] transition-colors hover:bg-[#f4f7ff]"
onClick={handleSelectFile}
type="button"
>
选择文件
</button>
</div>
<ImageNodeEmptyState
detailProgressLabel={detailProgressLabel}
fileInputRef={fileInputRef}
onImageUpload={handleImageUpload}
onSelectFile={handleSelectFile}
status={data.status}
/>
)}
{/* 处理中遮罩 */}
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-white">
<div className="flex flex-col items-center">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-[#f6f8ff] text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63]">处理中...</span>
</div>
</div>
<ImageNodeProcessingOverlay />
)}
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
@ -8924,159 +8739,30 @@ export function ImageNode({
) : null}
{/* 提示词重绘对话框 */}
<Dialog onOpenChange={setPromptDialogOpen} open={promptDialogOpen}>
<DialogContent
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
overlayClassName="bg-black"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5" />
提示词重绘
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-muted-foreground text-sm">
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
</p>
<div className="space-y-2">
<Label htmlFor={`prompt-regenerate-${node.id}`}>新提示词</Label>
<Textarea
className="resize-y border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
id={`prompt-regenerate-${node.id}`}
onChange={(e) => setRegeneratePrompt(e.target.value)}
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
rows={6}
value={regeneratePrompt}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() => setPromptDialogOpen(false)}
variant="outline"
>
取消
</Button>
<Button
disabled={isProcessing || !regeneratePrompt.trim()}
onClick={handlePromptRegenerate}
>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
开始重绘
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PromptRegenerateDialog
isProcessing={isProcessing}
nodeId={node.id}
onConfirm={handlePromptRegenerate}
onOpenChange={setPromptDialogOpen}
onPromptChange={setRegeneratePrompt}
open={promptDialogOpen}
prompt={regeneratePrompt}
/>
<Dialog
onOpenChange={(open) => {
if (!open) {
setThreedGenerationDialogMode(null);
}
}}
open={threedGenerationDialogMode !== null}
>
<DialogContent
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
overlayClassName="bg-black"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{threedGenerationDialogMode === "world" ? (
<Globe2 className="h-5 w-5" />
) : (
<Box className="h-5 w-5" />
)}
{threedGenerationDialogMode === "world"
? "选择 3D 世界模型"
: "选择 3D 模型"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-muted-foreground text-sm">
{threedGenerationDialogMode === "world"
? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。"
: "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"}
</p>
<div className="space-y-2">
<Label>
{threedGenerationDialogMode === "world" ? "世界模型" : "模型版本"}
</Label>
<Select
onValueChange={(value) => {
if (threedGenerationDialogMode === "world") {
setSelectedWorldModelVersion(value);
return;
}
setSelectedTripoModelVersion(value);
}}
value={
threedGenerationDialogMode === "world"
? selectedWorldModelVersion
: selectedTripoModelVersion
}
>
<SelectTrigger className="border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-950">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950">
{(threedGenerationDialogMode === "world"
? worldModelOptions
: tripoModelOptions
).map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-800 dark:bg-slate-900/80">
<div className="font-medium text-slate-900 dark:text-slate-100">
{threedGenerationDialogMode === "world"
? selectedWorldModelEntry?.displayName || "未选择模型"
: selectedTripoModelEntry?.displayName || "未选择模型"}
</div>
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
{threedGenerationDialogMode === "world"
? selectedWorldModelEntry?.description || "当前模型暂无说明。"
: selectedTripoModelEntry?.description || "当前模型暂无说明。"}
</div>
</div>
</div>
<DialogFooter>
<Button
onClick={() => setThreedGenerationDialogMode(null)}
variant="outline"
>
取消
</Button>
<Button
disabled={
isProcessing ||
(threedGenerationDialogMode === "world"
? !selectedWorldModelEntry
: !selectedTripoModelEntry)
}
onClick={() => void handleConfirmThreedGeneration()}
>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : threedGenerationDialogMode === "world" ? (
<Globe2 className="mr-2 h-4 w-4" />
) : (
<Box className="mr-2 h-4 w-4" />
)}
开始生成
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ThreeDGenerationDialog
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 &&

View File

@ -0,0 +1,122 @@
"use client";
import type React from "react";
const IMAGE_ADJUST_NOISE_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.18' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(#n)' opacity='1'/></svg>"
)}`;
export interface ImageAdjustmentOverlayState {
temperature: number;
tint: number;
grain: number;
vignette: number;
softLight: number;
glow: number;
}
interface ImageNodeAdjustOverlaysProps {
adjustments: ImageAdjustmentOverlayState;
hasActiveAdjustments: boolean;
style: React.CSSProperties;
}
export function ImageNodeAdjustOverlays({
adjustments,
hasActiveAdjustments,
style,
}: ImageNodeAdjustOverlaysProps) {
if (!hasActiveAdjustments) return null;
const temperatureOpacity = Math.min(
0.38,
Math.abs(adjustments.temperature) / 260
);
const tintOpacity = Math.min(0.28, Math.abs(adjustments.tint) / 300);
const grainOpacity = Math.min(0.28, adjustments.grain / 340);
const vignetteOpacity = Math.min(0.42, adjustments.vignette / 180);
const softLightOpacity = Math.min(0.24, adjustments.softLight / 320);
const glowOpacity = Math.min(0.22, adjustments.glow / 280);
return (
<>
{temperatureOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...style,
background:
adjustments.temperature >= 0
? "linear-gradient(135deg, rgba(255,188,109,0.9), rgba(255,232,194,0.42))"
: "linear-gradient(135deg, rgba(109,180,255,0.86), rgba(219,240,255,0.36))",
mixBlendMode: "soft-light",
opacity: temperatureOpacity,
}}
/>
) : null}
{tintOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...style,
background:
adjustments.tint >= 0
? "linear-gradient(135deg, rgba(244,105,214,0.78), rgba(255,203,236,0.28))"
: "linear-gradient(135deg, rgba(76,214,141,0.76), rgba(194,255,221,0.28))",
mixBlendMode: "overlay",
opacity: tintOpacity,
}}
/>
) : null}
{softLightOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...style,
background:
"linear-gradient(180deg, rgba(255,247,229,0.84) 0%, rgba(255,255,255,0.08) 34%, rgba(255,219,168,0.42) 100%)",
mixBlendMode: "soft-light",
opacity: softLightOpacity,
}}
/>
) : null}
{glowOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...style,
boxShadow:
"inset 0 0 72px rgba(255,255,255,0.78), inset 0 0 24px rgba(255,236,201,0.66)",
mixBlendMode: "screen",
opacity: glowOpacity,
}}
/>
) : null}
{vignetteOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...style,
background:
"radial-gradient(circle at center, rgba(0,0,0,0) 38%, rgba(0,0,0,0.06) 58%, rgba(0,0,0,0.34) 100%)",
mixBlendMode: "multiply",
opacity: vignetteOpacity,
}}
/>
) : null}
{grainOpacity > 0 ? (
<div
className="pointer-events-none absolute"
style={{
...style,
backgroundImage: `url("${IMAGE_ADJUST_NOISE_DATA_URI}")`,
backgroundRepeat: "repeat",
backgroundSize: "140px 140px",
mixBlendMode: "overlay",
opacity: grainOpacity,
}}
/>
) : null}
</>
);
}

View File

@ -0,0 +1,215 @@
"use client";
import { Box, Globe2, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
interface ThreeDModelOption {
id: string;
displayName: string;
description?: string | null;
}
type ThreeDGenerationDialogMode = "model" | "world" | null;
interface PromptRegenerateDialogProps {
isProcessing: boolean;
nodeId: string;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
onPromptChange: (value: string) => void;
open: boolean;
prompt: string;
}
interface ThreeDGenerationDialogProps {
isProcessing: boolean;
mode: ThreeDGenerationDialogMode;
onConfirm: () => void;
onModeChange: (mode: ThreeDGenerationDialogMode) => void;
onTripoModelChange: (value: string) => void;
onWorldModelChange: (value: string) => void;
selectedTripoModelEntry?: ThreeDModelOption | null;
selectedTripoModelVersion: string;
selectedWorldModelEntry?: ThreeDModelOption | null;
selectedWorldModelVersion: string;
tripoModelOptions: ThreeDModelOption[];
worldModelOptions: ThreeDModelOption[];
}
export function PromptRegenerateDialog({
isProcessing,
nodeId,
onConfirm,
onOpenChange,
onPromptChange,
open,
prompt,
}: PromptRegenerateDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
overlayClassName="bg-black"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5" />
提示词重绘
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-muted-foreground text-sm">
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
</p>
<div className="space-y-2">
<Label htmlFor={`prompt-regenerate-${nodeId}`}>新提示词</Label>
<Textarea
className="resize-y border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
id={`prompt-regenerate-${nodeId}`}
onChange={(event) => onPromptChange(event.target.value)}
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
rows={6}
value={prompt}
/>
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} variant="outline">
取消
</Button>
<Button disabled={isProcessing || !prompt.trim()} onClick={onConfirm}>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
开始重绘
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ThreeDGenerationDialog({
isProcessing,
mode,
onConfirm,
onModeChange,
onTripoModelChange,
onWorldModelChange,
selectedTripoModelEntry,
selectedTripoModelVersion,
selectedWorldModelEntry,
selectedWorldModelVersion,
tripoModelOptions,
worldModelOptions,
}: ThreeDGenerationDialogProps) {
const isWorldMode = mode === "world";
const selectedEntry = isWorldMode
? selectedWorldModelEntry
: selectedTripoModelEntry;
const options = isWorldMode ? worldModelOptions : tripoModelOptions;
return (
<Dialog
onOpenChange={(open) => {
if (!open) {
onModeChange(null);
}
}}
open={mode !== null}
>
<DialogContent
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
overlayClassName="bg-black"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isWorldMode ? (
<Globe2 className="h-5 w-5" />
) : (
<Box className="h-5 w-5" />
)}
{isWorldMode ? "选择 3D 世界模型" : "选择 3D 模型"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-muted-foreground text-sm">
{isWorldMode
? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。"
: "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"}
</p>
<div className="space-y-2">
<Label>{isWorldMode ? "世界模型" : "模型版本"}</Label>
<Select
onValueChange={(value) => {
if (isWorldMode) {
onWorldModelChange(value);
return;
}
onTripoModelChange(value);
}}
value={
isWorldMode ? selectedWorldModelVersion : selectedTripoModelVersion
}
>
<SelectTrigger className="border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-950">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950">
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-800 dark:bg-slate-900/80">
<div className="font-medium text-slate-900 dark:text-slate-100">
{selectedEntry?.displayName || "未选择模型"}
</div>
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
{selectedEntry?.description || "当前模型暂无说明。"}
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => onModeChange(null)} variant="outline">
取消
</Button>
<Button
disabled={isProcessing || !selectedEntry}
onClick={() => void onConfirm()}
>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : isWorldMode ? (
<Globe2 className="mr-2 h-4 w-4" />
) : (
<Box className="mr-2 h-4 w-4" />
)}
开始生成
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,100 @@
"use client";
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
import type React from "react";
interface ImageNodeEmptyStateProps {
detailProgressLabel?: string;
fileInputRef: React.Ref<HTMLInputElement>;
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
status?: string;
}
export function ImageNodeDeferredLoadState() {
return (
<div className="flex h-full w-full items-center justify-center bg-slate-50 text-slate-400 text-xs dark:bg-zinc-900 dark:text-zinc-500">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>图片即将进入视口时加载</span>
</div>
</div>
);
}
export function ImageNodeProcessingOverlay() {
return (
<div className="absolute inset-0 flex items-center justify-center bg-white">
<div className="flex flex-col items-center">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-[#f6f8ff] text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63]">处理中...</span>
</div>
</div>
);
}
export function ImageNodeEmptyState({
detailProgressLabel,
fileInputRef,
onImageUpload,
onSelectFile,
status,
}: ImageNodeEmptyStateProps) {
if (status === "generating") {
return (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="select-none px-3 text-sm font-medium text-[#3f4b63]">
{detailProgressLabel || "生成中..."}
</span>
<span className="mt-1 text-xs text-[#7a8395]">图片处理中</span>
</div>
);
}
if (status === "failed") {
return (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
<AlertCircle className="h-5 w-5" />
</span>
<span className="select-none text-sm font-medium text-[#c84b5e]">
生成失败
</span>
<span className="mt-1 text-xs text-[#b77480]">请重新上传或再试一次</span>
</div>
);
}
return (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<ImagePlus className="h-5 w-5" />
</span>
<span className="select-none text-sm font-medium text-[#3f4b63]">
上传图片
</span>
<span className="mb-4 mt-1 select-none text-xs text-[#7a8395]">
添加参考图、角色图或关键帧素材
</span>
<input
accept="image/*"
className="hidden"
onChange={onImageUpload}
ref={fileInputRef}
type="file"
/>
<button
className="cursor-pointer rounded-[14px] border border-[#d7dff1] bg-white px-4 py-2 text-sm font-medium text-[#3954db] shadow-[0_0_0_1px_rgba(219,226,238,0.24)] transition-colors hover:bg-[#f4f7ff]"
onClick={onSelectFile}
type="button"
>
选择文件
</button>
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { ImagePlus } from "lucide-react";
import type React from "react";
import { Input } from "@/components/ui/input";
interface ImageNodeSelectedTitleProps {
displayTitle: string;
draftTitle: string;
isEditingTitle: boolean;
onDraftTitleChange: (value: string) => void;
onCancelEditing: () => void;
onSaveTitle: () => void;
onStartEditing: () => void;
resolutionLabel?: string;
style?: React.CSSProperties;
}
export function ImageNodeSelectedTitle({
displayTitle,
draftTitle,
isEditingTitle,
onCancelEditing,
onDraftTitleChange,
onSaveTitle,
onStartEditing,
resolutionLabel,
style,
}: ImageNodeSelectedTitleProps) {
return (
<div
className="-top-7 absolute right-0 left-0 z-10 flex items-center justify-between px-1 text-[#2f7df6] text-xs"
style={style}
>
<div className="flex min-w-0 items-center gap-1">
<ImagePlus className="h-3 w-3" />
{isEditingTitle ? (
<Input
autoFocus
className="h-6 w-[160px] px-2 py-0 text-xs"
data-canvas-no-drag="true"
onBlur={onSaveTitle}
onChange={(event) => onDraftTitleChange(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSaveTitle();
} else if (event.key === "Escape") {
event.preventDefault();
onDraftTitleChange(displayTitle);
onCancelEditing();
}
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
value={draftTitle}
/>
) : (
<span
className="max-w-[150px] cursor-text truncate"
onDoubleClick={(event) => {
event.stopPropagation();
onDraftTitleChange(displayTitle);
onStartEditing();
}}
title="双击重命名"
>
{displayTitle}
</span>
)}
</div>
{resolutionLabel ? (
<span className="ml-2 shrink-0 tabular-nums tracking-wide">
{resolutionLabel}
</span>
) : null}
</div>
);
}