Enhance canvas workflow import and node controls
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user