Enhance canvas workflow import and node controls

This commit is contained in:
FengLee
2026-05-11 17:41:45 +08:00
parent 0962b4f3fc
commit e3454c7fba
3 changed files with 1739 additions and 271 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -23,11 +23,12 @@ import {
type Viewport as FlowViewport,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Brush, FileImage, Image as ImageIcon, Layers, Link2, Loader2, Maximize2, Move, Type } from 'lucide-react';
import { Brush, Download, Eye, FileImage, Image as ImageIcon, Layers, Link2, Loader2, Maximize2, Move, Sparkles, Type, Wand2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CanvasConnection, CanvasLayer, CanvasNode, CanvasViewport } from '@/lib/canvas-types';
type NodePositionCommit = { id: string; x: number; y: number };
type CanvasModelOption = { id: string; label: string };
type CanvasFlowNodeData = {
node: CanvasNode;
@@ -35,10 +36,17 @@ type CanvasFlowNodeData = {
connecting: boolean;
connections: CanvasConnection[];
allNodes: CanvasNode[];
modelOptions: CanvasModelOption[];
layerCanvasSize: number;
layerColors: Record<CanvasLayer['type'], string>;
onSelect: (id: string, additive: boolean) => void;
onStartConnect: (id: string) => void;
onUpdateNode: (id: string, patch: Partial<CanvasNode>) => void;
onRunNode: (id: string) => void;
onRunDownstreamNode: (id: string) => void;
onCreateVariationNode: (id: string) => void;
onPreviewNode: (id: string) => void;
onDownloadNode: (id: string) => void;
onRemoveConnection: (id: string) => void;
};
@@ -47,6 +55,7 @@ type CanvasFlowNode = FlowNode<CanvasFlowNodeData, 'canvasNode'>;
type ReactFlowCanvasProps = {
nodes: CanvasNode[];
connections: CanvasConnection[];
modelOptions: CanvasModelOption[];
viewport: CanvasViewport;
selectedNodeIds: string[];
connectingFromId: string | null;
@@ -58,6 +67,12 @@ type ReactFlowCanvasProps = {
onSelectNode: (id: string, additive: boolean) => void;
onSelectionChange: (ids: string[]) => void;
onStartConnect: (id: string) => void;
onUpdateNode: (id: string, patch: Partial<CanvasNode>) => void;
onRunNode: (id: string) => void;
onRunDownstreamNode: (id: string) => void;
onCreateVariationNode: (id: string) => void;
onPreviewNode: (id: string) => void;
onDownloadNode: (id: string) => void;
onConnect: (sourceId: string, targetId: string) => void;
onRemoveConnection: (id: string) => void;
onRemoveConnections: (ids: string[]) => void;
@@ -75,11 +90,21 @@ export type CanvasFlowControls = {
getViewport: () => CanvasViewport;
};
const CONNECTION_COLORS = ['#22c55e', '#06b6d4', '#8b5cf6', '#f59e0b', '#ef4444', '#14b8a6'];
const SNAP_GRID: [number, number] = [20, 20];
const MULTI_SELECTION_KEYS = ['Meta', 'Control', 'Shift'];
const FLOW_FIT_VIEW_OPTIONS = { padding: 0.2 };
const CANVAS_NODE_TYPES = { canvasNode: CanvasNodeCard };
const NODE_TYPE_META: Record<CanvasNode['type'], { label: string; accent: string; bg: string }> = {
text: { label: 'TEXT', accent: '#2563eb', bg: 'rgba(37, 99, 235, 0.1)' },
image: { label: 'ASSET', accent: '#2563eb', bg: 'rgba(37, 99, 235, 0.1)' },
text2img: { label: 'T2I', accent: '#7c3aed', bg: 'rgba(124, 58, 237, 0.1)' },
img2img: { label: 'I2I', accent: '#db2777', bg: 'rgba(219, 39, 119, 0.1)' },
layeredImage: { label: 'PSD', accent: '#ca8a04', bg: 'rgba(202, 138, 4, 0.12)' },
frame: { label: 'GROUP', accent: '#7c3aed', bg: 'rgba(124, 58, 237, 0.08)' },
};
const TEXT_IMAGE_ASPECT_OPTIONS = ['1:1', '16:9', '9:16', '4:3', '3:4'];
const IMAGE_REFERENCE_ASPECT_OPTIONS = ['original', ...TEXT_IMAGE_ASPECT_OPTIONS];
const RESOLUTION_OPTIONS = ['1080P', '2K', '4K'];
function getNodeImageUrl(node?: CanvasNode | null) {
if (!node) return '';
@@ -87,10 +112,155 @@ function getNodeImageUrl(node?: CanvasNode | null) {
return node.selectedOutput || node.outputImages?.[0] || '';
}
function getNodeTextValue(node?: CanvasNode | null) {
if (!node || node.type !== 'text') return '';
return node.text?.trim() || '';
}
function NodeOutputStrip({
node,
onUpdateNode,
}: {
node: CanvasNode;
onUpdateNode: (id: string, patch: Partial<CanvasNode>) => void;
}) {
const outputs = node.outputImages || [];
if (outputs.length <= 1) return null;
const activeOutput = node.selectedOutput || outputs[0];
return (
<div className="nodrag absolute bottom-2 left-2 right-2 z-20 flex gap-1 overflow-x-auto rounded-md border bg-background/90 p-1 shadow-sm backdrop-blur">
{outputs.map((src, index) => (
<button
key={`${src}-${index}`}
type="button"
className={cn(
'h-10 w-10 shrink-0 overflow-hidden rounded border bg-muted transition-all hover:border-primary',
activeOutput === src ? 'border-primary ring-2 ring-primary/30' : 'border-border',
)}
title={`选择结果 ${index + 1}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onUpdateNode(node.id, {
selectedOutput: src,
...(node.type === 'image' ? { imageUrl: src } : {}),
});
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`结果 ${index + 1}`} className="h-full w-full object-cover" />
</button>
))}
</div>
);
}
function NodeCountControl({
node,
onUpdateNode,
}: {
node: CanvasNode;
onUpdateNode: (id: string, patch: Partial<CanvasNode>) => void;
}) {
const count = Math.min(4, Math.max(1, node.params?.count || 1));
return (
<div className="nodrag flex shrink-0 rounded-md border bg-muted/30 p-0.5" title="生成张数">
{[1, 2, 4].map(value => (
<button
key={value}
type="button"
className={cn(
'h-6 min-w-6 rounded px-1.5 text-[11px] font-medium transition-colors',
count === value ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-background hover:text-foreground',
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onUpdateNode(node.id, { params: { ...node.params, count: value } });
}}
>
{value}
</button>
))}
</div>
);
}
function NodeInlineParams({
node,
hasReference,
modelOptions,
onUpdateNode,
}: {
node: CanvasNode;
hasReference: boolean;
modelOptions: CanvasModelOption[];
onUpdateNode: (id: string, patch: Partial<CanvasNode>) => void;
}) {
const aspectOptions = node.type === 'text2img' || (node.type === 'image' && !hasReference)
? TEXT_IMAGE_ASPECT_OPTIONS
: IMAGE_REFERENCE_ASPECT_OPTIONS;
const aspectRatio = node.params?.aspectRatio && aspectOptions.includes(node.params.aspectRatio)
? node.params.aspectRatio
: aspectOptions[0];
const resolution = node.params?.resolution && RESOLUTION_OPTIONS.includes(node.params.resolution)
? node.params.resolution
: '2K';
const modelValue = node.params?.model || modelOptions[0]?.id || '';
return (
<div className="nodrag mt-2 grid grid-cols-[minmax(0,1.2fr)_72px_72px] gap-2">
<select
className="h-7 min-w-0 rounded-md border bg-muted/30 px-2 text-[11px] text-foreground outline-none transition-colors focus:border-primary focus:bg-background disabled:cursor-not-allowed disabled:opacity-60"
value={modelValue}
title="生成模型"
disabled={modelOptions.length === 0}
onPointerDown={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { params: { ...node.params, model: event.target.value } })}
>
{modelOptions.length > 0 ? modelOptions.map(option => (
<option key={option.id} value={option.id}>{option.label}</option>
)) : <option value=""></option>}
</select>
<select
className="h-7 min-w-0 rounded-md border bg-muted/30 px-2 text-[11px] text-foreground outline-none transition-colors focus:border-primary focus:bg-background"
value={aspectRatio}
title="画面比例"
onPointerDown={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { params: { ...node.params, aspectRatio: event.target.value } })}
>
{aspectOptions.map(value => (
<option key={value} value={value}>{value === 'original' ? '原比例' : value}</option>
))}
</select>
<select
className="h-7 min-w-0 rounded-md border bg-muted/30 px-2 text-[11px] text-foreground outline-none transition-colors focus:border-primary focus:bg-background"
value={resolution}
title="分辨率"
onPointerDown={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { params: { ...node.params, resolution: event.target.value } })}
>
{RESOLUTION_OPTIONS.map(value => (
<option key={value} value={value}>{value}</option>
))}
</select>
</div>
);
}
function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
const { node, selected, connecting, connections, allNodes, layerCanvasSize, layerColors, onSelect, onStartConnect, onRemoveConnection } = data;
const incomingImage = allNodes.find(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id && !!getNodeImageUrl(item)));
const { node, selected, connecting, connections, allNodes, modelOptions, layerCanvasSize, layerColors, onSelect, onStartConnect, onUpdateNode, onRunNode, onRunDownstreamNode, onCreateVariationNode, onPreviewNode, onDownloadNode, onRemoveConnection } = data;
const incomingNodes = allNodes.filter(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id));
const incomingImage = incomingNodes.find(item => !!getNodeImageUrl(item));
const incomingText = incomingNodes.map(getNodeTextValue).filter(Boolean).join('\n');
const nodeConnections = connections.filter(connection => connection.sourceNodeId === node.id || connection.targetNodeId === node.id);
const hasOutgoing = connections.some(connection => connection.sourceNodeId === node.id);
const meta = NODE_TYPE_META[node.type];
const [editingText, setEditingText] = useState(false);
const [editingNegativePrompt, setEditingNegativePrompt] = useState(false);
const imageSrc = node.imageUrl || node.selectedOutput || node.outputImages?.[0] || '';
const generationImageSrc = node.selectedOutput || node.outputImages?.[0] || '';
const hasUsableImage = !!getNodeImageUrl(node);
const hasReferenceImage = !!(node.referenceImage || getNodeImageUrl(incomingImage));
return (
<div
@@ -98,24 +268,38 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
data-node-id={node.id}
className={cn(
'group h-full overflow-visible rounded-lg border transition-shadow',
node.type === 'frame' ? 'bg-background/20 shadow-none' : 'bg-card shadow-sm',
selected ? 'border-primary ring-2 ring-primary/20' : connecting ? 'border-emerald-500 ring-2 ring-emerald-500/20' : 'border-border',
node.type === 'frame' ? 'border-dashed bg-background/10 shadow-none' : 'bg-card shadow-md',
selected ? 'border-primary ring-2 ring-primary/30' : connecting ? 'border-primary ring-2 ring-primary/25' : 'border-border',
)}
style={{ borderColor: node.type === 'frame' && !selected ? node.color || '#22c55e' : undefined }}
style={{ borderColor: node.type === 'frame' && !selected ? node.color || meta.accent : undefined }}
onPointerDown={(event) => {
const target = event.target as HTMLElement;
if (target.closest('button,input,textarea,[role="combobox"],.nodrag')) return;
onSelect(node.id, event.ctrlKey || event.metaKey || event.shiftKey);
}}
>
<Handle type="target" position={Position.Left} id="input" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
<Handle type="source" position={Position.Right} id="output" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
<Handle
type="target"
position={Position.Left}
id="input"
className="nodrag nopan !left-[-9px] !z-30 !h-5 !w-5 !border-2 !border-background !opacity-100"
style={{ backgroundColor: meta.accent }}
/>
<span className="nodrag pointer-events-none absolute -left-9 top-1/2 z-20 -translate-y-1/2 rounded-full border bg-background px-1.5 py-0.5 text-[9px] font-semibold text-muted-foreground shadow-sm">IN</span>
<Handle
type="source"
position={Position.Right}
id="output"
className="nodrag nopan !right-[-9px] !z-30 !h-5 !w-5 !border-2 !border-background !opacity-100"
style={{ backgroundColor: meta.accent }}
/>
<span className="nodrag pointer-events-none absolute -right-11 top-1/2 z-20 -translate-y-1/2 rounded-full border bg-background px-1.5 py-0.5 text-[9px] font-semibold text-muted-foreground shadow-sm">OUT</span>
<button
type="button"
className={cn(
'nodrag absolute right-0 top-1/2 z-20 flex h-7 w-7 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100',
connecting ? 'border-emerald-500 bg-emerald-500 text-white opacity-100' : '',
'nodrag absolute -right-3 top-3 z-20 flex h-7 w-7 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100',
connecting ? 'border-primary bg-primary text-primary-foreground opacity-100' : '',
)}
title="从此模块开始连线"
onPointerDown={(event) => event.stopPropagation()}
@@ -127,8 +311,39 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
<Link2 className="h-3.5 w-3.5" />
</button>
{hasOutgoing ? (
<button
type="button"
className="nodrag absolute -right-3 top-12 z-20 flex h-7 w-7 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100"
title="运行下游"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRunDownstreamNode(node.id);
}}
>
<Sparkles className="h-3.5 w-3.5" />
</button>
) : null}
{hasUsableImage ? (
<button
type="button"
className="nodrag absolute -right-3 top-[5.25rem] z-20 flex h-7 w-7 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100"
title="用当前图继续创作"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onCreateVariationNode(node.id);
}}
>
<Wand2 className="h-3.5 w-3.5" />
</button>
) : null}
<div className="h-full overflow-hidden rounded-lg">
<div className="flex h-10 items-center justify-between border-b bg-muted/40 px-3">
<div className="h-1.5" style={{ backgroundColor: meta.accent }} />
<div className="flex h-11 items-center justify-between border-b bg-muted/40 px-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
{node.type === 'text' ? <Type className="h-4 w-4" /> : null}
{node.type === 'image' ? <ImageIcon className="h-4 w-4" /> : null}
@@ -138,32 +353,209 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
{node.type === 'frame' ? <Maximize2 className="h-4 w-4" /> : null}
<span className="truncate">{node.title}</span>
</div>
{node.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin text-primary" /> : <Move className="h-4 w-4 text-muted-foreground" />}
<div className="flex items-center gap-2">
<span className="rounded-full px-2 py-0.5 text-[10px] font-semibold" style={{ color: meta.accent, backgroundColor: meta.bg }}>{meta.label}</span>
{node.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin text-primary" /> : <Move className="h-4 w-4 text-muted-foreground" />}
</div>
</div>
<div className="h-[calc(100%-2.5rem)] p-3">
<div className="h-[calc(100%-2.875rem-0.375rem)] p-3">
{node.type === 'text' ? (
<div className="h-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-sm leading-6">{node.text}</div>
editingText ? (
<textarea
className="nodrag nowheel h-full w-full resize-none rounded-md border border-primary/40 bg-background p-3 text-sm leading-6 outline-none ring-2 ring-primary/20"
value={node.text || ''}
autoFocus
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { text: event.target.value })}
onBlur={() => setEditingText(false)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
setEditingText(false);
}
}}
/>
) : (
<button
type="button"
className="nodrag h-full w-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-left text-sm leading-6 text-foreground transition-colors hover:bg-muted/50"
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => {
event.stopPropagation();
setEditingText(true);
}}
>
{node.text || '双击输入文本'}
</button>
)
) : null}
{node.type === 'image' ? (
<div className="h-full overflow-hidden rounded-md bg-muted">
{node.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={node.imageUrl} alt={node.title} className="h-full w-full object-contain" />
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"></div>
)}
<div className="flex h-full flex-col gap-2">
<div className="relative min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
{imageSrc ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageSrc} alt={node.title} className="h-full w-full object-contain" />
<div className="nodrag absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md border bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
title="预览图片"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onPreviewNode(node.id);
}}
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md border bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
title="下载图片"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onDownloadNode(node.id);
}}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
<NodeOutputStrip node={node} onUpdateNode={onUpdateNode} />
</>
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">
{node.status === 'failed' ? node.error || '生成失败' : node.status === 'generating' ? '生成中...' : incomingText ? '已连接提示词,点击生成图片' : '连接文本节点或输入提示词'}
</div>
)}
</div>
<div className="nodrag rounded-md border bg-background/95 p-2 shadow-sm">
<textarea
className="nowheel h-14 w-full resize-none rounded-md border bg-muted/30 px-2 py-1.5 text-xs leading-5 outline-none transition-colors focus:border-primary focus:bg-background"
placeholder={incomingText ? '可补充描述;会叠加上游文本' : '输入创作描述,或从文本节点连到这里'}
value={node.prompt || ''}
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { prompt: event.target.value })}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[11px] text-muted-foreground">
{incomingText ? `上游:${incomingText}` : incomingImage ? '已连接参考图' : '图片节点可直接生成'}
</div>
<NodeCountControl node={node} onUpdateNode={onUpdateNode} />
<button
type="button"
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md bg-primary px-2 text-xs font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-60"
disabled={node.status === 'generating'}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRunNode(node.id);
}}
>
{node.status === 'generating' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
</button>
</div>
<NodeInlineParams node={node} hasReference={hasReferenceImage} modelOptions={modelOptions} onUpdateNode={onUpdateNode} />
<div className="mt-2">
{editingNegativePrompt ? (
<textarea
className="nowheel h-11 w-full resize-none rounded-md border bg-muted/30 px-2 py-1.5 text-xs leading-5 outline-none transition-colors focus:border-primary focus:bg-background"
placeholder="负面提示词,如:低清晰度、畸形、错误文字"
value={node.negativePrompt || ''}
autoFocus
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { negativePrompt: event.target.value })}
onBlur={() => {
if (!node.negativePrompt?.trim()) setEditingNegativePrompt(false);
}}
/>
) : (
<button
type="button"
className="w-full rounded-md border border-dashed bg-muted/20 px-2 py-1.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-primary hover:text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setEditingNegativePrompt(true);
}}
>
{node.negativePrompt?.trim() ? `负面:${node.negativePrompt}` : '添加负面提示词'}
</button>
)}
</div>
</div>
</div>
) : null}
{node.type === 'text2img' || node.type === 'img2img' ? (
<div className="flex h-full flex-col gap-3">
<div className="line-clamp-3 min-h-14 rounded-md bg-muted/30 p-3 text-sm text-muted-foreground">
{node.prompt || (connections.some(connection => connection.targetNodeId === node.id) ? '已连接上游内容,可继续补充描述' : '在右侧输入创作描述')}
<div className="flex h-full flex-col gap-2">
<div className="nodrag rounded-md border bg-background/95 p-2 shadow-sm">
<textarea
className="nowheel h-14 w-full resize-none rounded-md border bg-muted/30 px-2 py-1.5 text-xs leading-5 outline-none transition-colors focus:border-primary focus:bg-background"
placeholder={incomingText ? '可补充描述;会叠加上游文本' : '输入创作描述,或从文本节点连到这里'}
value={node.prompt || ''}
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { prompt: event.target.value })}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[11px] text-muted-foreground">
{incomingText ? `上游:${incomingText}` : node.type === 'img2img' && hasReferenceImage ? '已连接参考图' : '节点内可直接生成'}
</div>
<NodeCountControl node={node} onUpdateNode={onUpdateNode} />
<button
type="button"
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md bg-primary px-2 text-xs font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-60"
disabled={node.status === 'generating'}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRunNode(node.id);
}}
>
{node.status === 'generating' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
</button>
</div>
<NodeInlineParams node={node} hasReference={hasReferenceImage} modelOptions={modelOptions} onUpdateNode={onUpdateNode} />
<div className="mt-2">
{editingNegativePrompt ? (
<textarea
className="nowheel h-11 w-full resize-none rounded-md border bg-muted/30 px-2 py-1.5 text-xs leading-5 outline-none transition-colors focus:border-primary focus:bg-background"
placeholder="负面提示词,如:低清晰度、畸形、错误文字"
value={node.negativePrompt || ''}
autoFocus
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { negativePrompt: event.target.value })}
onBlur={() => {
if (!node.negativePrompt?.trim()) setEditingNegativePrompt(false);
}}
/>
) : (
<button
type="button"
className="w-full rounded-md border border-dashed bg-muted/20 px-2 py-1.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-primary hover:text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setEditingNegativePrompt(true);
}}
>
{node.negativePrompt?.trim() ? `负面:${node.negativePrompt}` : '添加负面提示词'}
</button>
)}
</div>
</div>
{node.type === 'img2img' ? (
<div className="h-20 overflow-hidden rounded-md border bg-muted">
<div className="h-16 overflow-hidden rounded-md border bg-muted">
{node.referenceImage || getNodeImageUrl(incomingImage) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={node.referenceImage || getNodeImageUrl(incomingImage)} alt="参考图" className="h-full w-full object-contain" />
@@ -172,13 +564,42 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
)}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
{node.selectedOutput ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={node.selectedOutput} alt="生成结果" className="h-full w-full object-contain" />
<div className="relative min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
{generationImageSrc ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={generationImageSrc} alt="生成结果" className="h-full w-full object-contain" />
<div className="nodrag absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md border bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
title="预览图片"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onPreviewNode(node.id);
}}
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md border bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
title="下载图片"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onDownloadNode(node.id);
}}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
<NodeOutputStrip node={node} onUpdateNode={onUpdateNode} />
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{node.status === 'failed' ? node.error || '生成失败' : node.status === 'generating' ? '生成中...' : '等待生成'}
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">
{node.status === 'failed' ? node.error || '生成失败' : node.status === 'generating' ? '生成中...' : '等待节点内生成'}
</div>
)}
</div>
@@ -222,8 +643,9 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
) : null}
{node.type === 'frame' ? (
<div className="flex h-full flex-col justify-end rounded-md border border-dashed bg-background/25 p-3 text-xs text-muted-foreground" style={{ borderColor: node.color || '#22c55e' }}>
<div className="rounded-md bg-background/80 px-3 py-2 shadow-sm">{node.text || '流程分组'}</div>
<div className="flex h-full flex-col justify-end rounded-md border border-dashed bg-background/25 p-3 text-xs text-muted-foreground" style={{ borderColor: node.color || meta.accent }}>
<div className="mb-auto inline-flex w-fit rounded-full px-2 py-1 text-[10px] font-semibold" style={{ color: meta.accent, backgroundColor: meta.bg }}>GROUP FRAME</div>
<div className="rounded-md bg-background/90 px-3 py-2 shadow-sm">{node.text || '流程分组'}</div>
</div>
) : null}
</div>
@@ -276,7 +698,13 @@ function sameFlowNodes(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
&& node.data.selected === next.data.selected
&& node.data.connecting === next.data.connecting
&& node.data.connections === next.data.connections
&& node.data.allNodes === next.data.allNodes;
&& node.data.allNodes === next.data.allNodes
&& node.data.modelOptions === next.data.modelOptions
&& node.data.onRunNode === next.data.onRunNode
&& node.data.onRunDownstreamNode === next.data.onRunDownstreamNode
&& node.data.onCreateVariationNode === next.data.onCreateVariationNode
&& node.data.onPreviewNode === next.data.onPreviewNode
&& node.data.onDownloadNode === next.data.onDownloadNode;
});
}
@@ -291,6 +719,7 @@ function clampViewport(viewport: FlowViewport, minZoom: number, maxZoom: number)
function FlowCanvasInner({
nodes,
connections,
modelOptions,
viewport,
selectedNodeIds,
connectingFromId,
@@ -302,6 +731,12 @@ function FlowCanvasInner({
onSelectNode,
onSelectionChange,
onStartConnect,
onUpdateNode,
onRunNode,
onRunDownstreamNode,
onCreateVariationNode,
onPreviewNode,
onDownloadNode,
onConnect,
onRemoveConnection,
onRemoveConnections,
@@ -332,35 +767,40 @@ function FlowCanvasInner({
connecting: connectingFromId === node.id,
connections,
allNodes: nodes,
modelOptions,
layerCanvasSize,
layerColors,
onSelect: onSelectNode,
onStartConnect,
onUpdateNode,
onRunNode,
onRunDownstreamNode,
onCreateVariationNode,
onPreviewNode,
onDownloadNode,
onRemoveConnection,
},
style: {
width: node.width,
height: node.height,
},
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, nodes, onRemoveConnection, onSelectNode, onStartConnect, selectedNodeIds]);
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, modelOptions, nodes, onCreateVariationNode, onDownloadNode, onPreviewNode, onRemoveConnection, onRunDownstreamNode, onRunNode, onSelectNode, onStartConnect, onUpdateNode, selectedNodeIds]);
const [flowNodes, setFlowNodes] = useState<CanvasFlowNode[]>(incomingNodes);
const flowEdges = useMemo<Edge[]>(() => connections.map((connection, index) => ({
const flowEdges = useMemo<Edge[]>(() => connections.map((connection) => ({
id: connection.id,
source: connection.sourceNodeId,
target: connection.targetNodeId,
sourceHandle: 'output',
targetHandle: 'input',
label: connection.label,
type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed },
style: {
stroke: CONNECTION_COLORS[index % CONNECTION_COLORS.length],
strokeWidth: 2.5,
stroke: 'hsl(var(--foreground) / 0.62)',
strokeWidth: 2.25,
},
labelBgStyle: { fill: 'hsl(var(--background))', fillOpacity: 0.9 },
labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 12 },
interactionWidth: 18,
})), [connections]);
useEffect(() => {
@@ -508,7 +948,7 @@ function FlowCanvasInner({
zoomOnScroll
zoomOnPinch
panOnScroll={false}
panOnDrag
panOnDrag={[0, 1]}
selectionOnDrag={false}
multiSelectionKeyCode={MULTI_SELECTION_KEYS}
deleteKeyCode={null}
@@ -530,8 +970,8 @@ function FlowCanvasInner({
onSelectionChange={handleSelectionChange}
onInit={handleInit}
>
<Background gap={20} size={1} color="hsl(var(--border))" />
<Background gap={100} size={1.2} color="hsl(var(--muted-foreground))" />
<Background gap={20} size={1} color="hsl(var(--foreground) / 0.16)" />
<Background gap={100} size={1.5} color="hsl(var(--primary) / 0.28)" />
<Controls showInteractive={false} position="bottom-left" />
<MiniMap
position="bottom-right"

View File

@@ -22,6 +22,16 @@ export type CanvasConnection = {
createdAt: string;
};
export type CanvasGroup = {
id: string;
title: string;
nodeIds: string[];
frameNodeId?: string;
color?: string;
createdAt: string;
updatedAt: string;
};
export type CanvasLayer = {
id: string;
name: string;
@@ -74,6 +84,7 @@ export type CanvasNode = {
export type CanvasProjectState = {
nodes: CanvasNode[];
connections: CanvasConnection[];
groups: CanvasGroup[];
assets: CanvasAsset[];
viewport: CanvasViewport;
};
@@ -91,6 +102,7 @@ export function createEmptyCanvasState(): CanvasProjectState {
return {
nodes: [],
connections: [],
groups: [],
assets: [],
viewport: { x: 0, y: 0, zoom: 1 },
};