Files
miaojingAI/src/components/canvas/infinite-canvas-workspace.tsx
2026-05-09 23:54:18 +08:00

2138 lines
95 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import {
Brush,
BookOpen,
ChevronDown,
Copy,
Download,
FileImage,
FolderOpen,
Image as ImageIcon,
Layers,
Link2,
Loader2,
Maximize2,
MousePointer2,
Plus,
RotateCcw,
Save,
Sparkles,
StickyNote,
Trash2,
Type,
Undo2,
Upload,
Wand2,
Redo2,
RefreshCw,
Send,
ShoppingCart,
UserRound,
ZoomIn,
ZoomOut,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { useAuth } from '@/lib/auth-store';
import { useCustomApiKeys } from '@/lib/custom-api-store';
import { useManagedSystemApis } from '@/lib/managed-model-store';
import {
ASPECT_RATIOS,
IMG2IMG_ASPECT_RATIOS,
RESOLUTION_OPTIONS,
buildCustomModelId,
buildSystemModelId,
getCustomKeyId,
getSystemApiId,
isCustomModel,
isSystemModel,
resolveCustomApiImageSize,
resolveImageSize,
resolveImageSizeFromDimensions,
} from '@/lib/model-config';
import { runGenerationJob } from '@/lib/generation-job-client';
import { cn } from '@/lib/utils';
import type { CanvasAsset, CanvasConnection, CanvasLayer, CanvasNode, CanvasNodeType, CanvasProject, CanvasProjectState, CanvasViewport } from '@/lib/canvas-types';
import type { CanvasFlowControls } from './react-flow-canvas';
const ReactFlowCanvas = dynamic(() => import('./react-flow-canvas').then(mod => mod.ReactFlowCanvas), {
ssr: false,
});
type AddMenu = {
x: number;
y: number;
screenX: number;
screenY: number;
} | null;
type WorkflowTemplateType =
| 'text2img'
| 'img2img'
| 'image-chain'
| 'layered'
| 'multi-angle-storyboard'
| 'product-ecommerce-full-set'
| 'drama-character-design'
| 'drama-scene-background'
| 'picture-book-generator';
type WorkflowTemplateMeta = {
id: WorkflowTemplateType;
name: string;
description: string;
badge: string;
icon: 'storyboard' | 'ecommerce' | 'character' | 'scene' | 'book';
cover?: string;
};
const LOCAL_DRAFT_KEY = 'miaojing:canvas:last-project-id';
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
const LAYER_CANVAS_SIZE = 1080;
const LAYER_COLORS: Record<CanvasLayer['type'], string> = {
background: '#f8fafc',
element: '#38bdf8',
icon: '#a78bfa',
text: '#111827',
effect: '#f59e0b',
};
const HUOBAO_WORKFLOW_TEMPLATES: WorkflowTemplateMeta[] = [
{
id: 'multi-angle-storyboard',
name: '多角度分镜',
description: '生成角色正视、侧视、后视、俯视四组分镜图。',
badge: 'Storyboard',
icon: 'storyboard',
cover: '/canvas/workflows/workflow01.jpeg',
},
{
id: 'product-ecommerce-full-set',
name: '通用产品全套电商图',
description: '根据产品信息和产品图生成模特图、展示图、拆解图。',
badge: 'E-commerce',
icon: 'ecommerce',
cover: '/canvas/workflows/workflow02.jpeg',
},
{
id: 'drama-character-design',
name: '短剧角色设计',
description: '从角色描述生成基准角色图,并衍生多角度角色素材。',
badge: 'Drama',
icon: 'character',
cover: '/canvas/workflows/shot01.jpeg',
},
{
id: 'drama-scene-background',
name: '多时段场景背景',
description: '为同一场景生成白天、黄昏、夜晚和雨天版本。',
badge: 'Scene',
icon: 'scene',
cover: '/canvas/workflows/scene01.jpeg',
},
{
id: 'picture-book-generator',
name: '儿童绘本生成',
description: '把故事设定拆成连续页面提示词和插画生成节点。',
badge: 'Book',
icon: 'book',
},
];
const QUICK_SUGGESTIONS = ['像个魔法森林', '三只不同的小猫', '生成多角度分镜', '夏日田野环绕漫步'];
function nowIso() {
return new Date().toISOString();
}
function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function sameStringArray(a: string[], b: string[]) {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('文件读取失败'));
reader.readAsDataURL(file);
});
}
function getImageDimensions(src: string): Promise<{ width?: number; height?: number }> {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth || image.width, height: image.naturalHeight || image.height });
image.onerror = () => resolve({});
image.src = src;
});
}
function createNode(type: CanvasNodeType, x: number, y: number, zIndex: number, patch: Partial<CanvasNode> = {}): CanvasNode {
const createdAt = nowIso();
const base: CanvasNode = {
id: createId(type),
type,
x,
y,
width: type === 'text' ? 300 : type === 'frame' ? 640 : 360,
height: type === 'text' ? 190 : type === 'frame' ? 420 : 420,
zIndex,
title: type === 'text'
? '文本'
: type === 'image'
? '图片'
: type === 'text2img'
? '文生图'
: type === 'img2img'
? '图生图'
: type === 'frame'
? '分组框'
: '图层图片',
status: 'idle',
params: {
aspectRatio: type === 'img2img' ? 'original' : '1:1',
resolution: '2K',
count: 1,
strength: 0.5,
},
createdAt,
updatedAt: createdAt,
};
if (type === 'text') base.text = '双击右侧属性面板编辑文本内容';
if (type === 'frame') {
base.color = '#22c55e';
base.text = '把相关模块拖到这个区域内,用来整理一组创作流程。';
}
if (type === 'layeredImage') {
base.layers = [
{ id: createId('layer'), name: '背景层', type: 'background', visible: true, locked: false, color: '#f8fafc', opacity: 1, x: 0, y: 0, width: 1080, height: 1080 },
{ id: createId('layer'), name: '主体元素层', type: 'element', visible: true, locked: false, color: '#38bdf8', opacity: 0.9, x: 120, y: 180, width: 840, height: 650 },
{ id: createId('layer'), name: '文字层', type: 'text', visible: true, locked: false, color: '#111827', opacity: 1, text: '可编辑文字', x: 120, y: 80, width: 840, height: 120 },
];
}
return { ...base, ...patch };
}
function getAuthHeaders(accessToken?: string | null) {
return {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
};
}
function getNodeImageUrl(node?: CanvasNode | null) {
if (!node) return '';
if (node.type === 'image') return node.imageUrl || '';
return node.selectedOutput || node.outputImages?.[0] || '';
}
function getNodeTextValue(node?: CanvasNode | null) {
if (!node || node.type !== 'text') return '';
return node.text?.trim() || '';
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 500);
}
function downloadJson(value: unknown, filename: string) {
downloadBlob(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }), filename);
}
function drawLayerPreview(ctx: CanvasRenderingContext2D, layer: CanvasLayer, scale = 1) {
if (!layer.visible) return;
const x = layer.x * scale;
const y = layer.y * scale;
const width = layer.width * scale;
const height = layer.height * scale;
ctx.save();
ctx.globalAlpha = layer.opacity ?? 1;
if (layer.type === 'text') {
ctx.fillStyle = layer.color || LAYER_COLORS.text;
ctx.font = `${Math.max(18, Math.min(72, height * 0.45))}px sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillText(layer.text || layer.name, x + 12 * scale, y + height / 2, Math.max(24, width - 24 * scale));
} else if (layer.assetUrl) {
ctx.fillStyle = layer.color || LAYER_COLORS[layer.type];
ctx.fillRect(x, y, width, height);
} else if (layer.type === 'background') {
ctx.fillStyle = layer.color || LAYER_COLORS.background;
ctx.fillRect(x, y, width, height);
} else {
const radius = Math.min(24 * scale, width / 5, height / 5);
ctx.fillStyle = layer.color || LAYER_COLORS[layer.type];
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
ctx.fill();
}
ctx.restore();
}
function renderLayeredNodeToCanvas(node: CanvasNode, size = LAYER_CANVAS_SIZE) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('当前浏览器不支持画布导出');
ctx.clearRect(0, 0, size, size);
const scale = size / LAYER_CANVAS_SIZE;
(node.layers || []).forEach(layer => drawLayerPreview(ctx, layer, scale));
return canvas;
}
function getCanvasBounds(nodes: CanvasNode[]) {
if (nodes.length === 0) return null;
const left = Math.min(...nodes.map(node => node.x));
const top = Math.min(...nodes.map(node => node.y));
const right = Math.max(...nodes.map(node => node.x + node.width));
const bottom = Math.max(...nodes.map(node => node.y + node.height));
return { left, top, right, bottom, width: right - left, height: bottom - top };
}
function WorkflowTemplateIcon({ icon, className }: { icon: WorkflowTemplateMeta['icon']; className?: string }) {
const Icon = icon === 'ecommerce'
? ShoppingCart
: icon === 'character'
? UserRound
: icon === 'scene'
? ImageIcon
: icon === 'book'
? BookOpen
: Layers;
return <Icon className={className} />;
}
export function InfiniteCanvasWorkspace() {
const { user, accessToken } = useAuth();
const { imageKeys } = useCustomApiKeys();
const managedSystemApis = useManagedSystemApis();
const fileInputRef = useRef<HTMLInputElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const flowControlsRef = useRef<CanvasFlowControls | null>(null);
const historyRef = useRef<{ past: CanvasProjectState[]; future: CanvasProjectState[]; skip: boolean }>({ past: [], future: [], skip: false });
const [projects, setProjects] = useState<CanvasProject[]>([]);
const [project, setProject] = useState<CanvasProject | null>(null);
const [state, setState] = useState<CanvasProjectState>({ nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } });
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
const [addMenu, setAddMenu] = useState<AddMenu>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [creatingProject, setCreatingProject] = useState(false);
const [connectingFromId, setConnectingFromId] = useState<string | null>(null);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [mounted, setMounted] = useState(false);
const [showNodeMenu, setShowNodeMenu] = useState(false);
const [showWorkflowPanel, setShowWorkflowPanel] = useState(false);
const [workflowTab, setWorkflowTab] = useState<'public' | 'my'>('public');
const [showProjectMenu, setShowProjectMenu] = useState(false);
const [chatInput, setChatInput] = useState('');
const [autoExecute, setAutoExecute] = useState(false);
const selectedNode = useMemo(
() => state.nodes.find(node => node.id === selectedNodeId) || null,
[selectedNodeId, state.nodes],
);
const selectedNodes = useMemo(
() => state.nodes.filter(node => selectedNodeIds.includes(node.id)),
[selectedNodeIds, state.nodes],
);
const selectedNodeConnections = useMemo(
() => selectedNode
? state.connections.filter(connection => connection.sourceNodeId === selectedNode.id || connection.targetNodeId === selectedNode.id)
: [],
[selectedNode, state.connections],
);
const canvasBounds = useMemo(() => getCanvasBounds(state.nodes), [state.nodes]);
const selectedBounds = useMemo(() => getCanvasBounds(selectedNodes), [selectedNodes]);
const systemImageApis = managedSystemApis.filter(api => api.type === 'image' && api.isActive);
const modelOptions = useMemo(() => [
...systemImageApis.map(api => ({
id: buildSystemModelId(api.id),
label: `${api.name} (系统)`,
modelName: api.modelName,
source: 'system' as const,
})),
...imageKeys.map(key => ({
id: buildCustomModelId(key.id),
label: `${key.modelName || key.provider} (自定义)`,
modelName: key.modelName,
source: 'custom' as const,
})),
], [systemImageApis, imageKeys]);
useEffect(() => {
setMounted(true);
}, []);
const mutateState = useCallback((updater: (current: CanvasProjectState) => CanvasProjectState, options?: { history?: boolean; dirty?: boolean }) => {
setState(current => {
const next = updater(current);
if (!historyRef.current.skip && options?.history !== false && next !== current) {
historyRef.current.past = [...historyRef.current.past.slice(-49), current];
historyRef.current.future = [];
}
return next;
});
if (options?.dirty !== false) {
setDirty(true);
}
}, []);
const replaceCanvasState = useCallback((nextState: CanvasProjectState, options?: { dirty?: boolean }) => {
historyRef.current.skip = true;
setState(nextState);
window.setTimeout(() => {
historyRef.current.skip = false;
}, 0);
setDirty(options?.dirty ?? false);
}, []);
const resetHistory = useCallback(() => {
historyRef.current = { past: [], future: [], skip: false };
}, []);
const screenToCanvasPoint = useCallback((clientX: number, clientY: number, viewport: CanvasProjectState['viewport'] = state.viewport) => {
const rect = canvasRef.current?.getBoundingClientRect();
const left = rect?.left || 0;
const top = rect?.top || 0;
return {
x: (clientX - left - viewport.x) / viewport.zoom,
y: (clientY - top - viewport.y) / viewport.zoom,
};
}, [state.viewport]);
const getViewportCenterPoint = useCallback(() => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return { x: 160, y: 160 };
return screenToCanvasPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
}, [screenToCanvasPoint]);
const loadProjects = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
try {
const res = await fetch('/api/canvas/projects', { headers: getAuthHeaders(accessToken) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '读取画布项目失败');
const nextProjects = Array.isArray(data.projects) ? data.projects as CanvasProject[] : [];
setProjects(nextProjects);
const lastId = window.localStorage.getItem(LOCAL_DRAFT_KEY);
const nextProject = nextProjects.find(item => item.id === lastId) || nextProjects[0] || null;
if (nextProject) {
setProject(nextProject);
replaceCanvasState({ ...nextProject.state, connections: nextProject.state.connections || [] });
resetHistory();
setSelectedNodeId(null);
setSelectedNodeIds([]);
setDirty(false);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '读取画布项目失败');
} finally {
setLoading(false);
}
}, [accessToken, replaceCanvasState, resetHistory]);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
const openProject = useCallback(async (id: string) => {
if (!accessToken) return;
setLoading(true);
try {
const res = await fetch(`/api/canvas/projects/${encodeURIComponent(id)}`, { headers: getAuthHeaders(accessToken) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '读取画布失败');
setProject(data.project as CanvasProject);
replaceCanvasState({
...(data.project as CanvasProject).state,
connections: (data.project as CanvasProject).state.connections || [],
});
resetHistory();
setSelectedNodeId(null);
setSelectedNodeIds([]);
setDirty(false);
window.localStorage.setItem(LOCAL_DRAFT_KEY, id);
} catch (error) {
toast.error(error instanceof Error ? error.message : '读取画布失败');
} finally {
setLoading(false);
}
}, [accessToken, replaceCanvasState, resetHistory]);
const createProject = useCallback(async () => {
if (!accessToken) {
toast.error('请先登录后再使用无限画布');
return;
}
setCreatingProject(true);
try {
const res = await fetch('/api/canvas/projects', {
method: 'POST',
headers: getAuthHeaders(accessToken),
body: JSON.stringify({ title: `创作画布 ${new Date().toLocaleDateString('zh-CN')}` }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '创建画布失败');
const nextProject = data.project as CanvasProject;
setProjects(prev => [nextProject, ...prev]);
setProject(nextProject);
replaceCanvasState({ ...nextProject.state, connections: nextProject.state.connections || [] });
resetHistory();
setSelectedNodeId(null);
setSelectedNodeIds([]);
setDirty(false);
window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
toast.success('已创建画布');
} catch (error) {
toast.error(error instanceof Error ? error.message : '创建画布失败');
} finally {
setCreatingProject(false);
}
}, [accessToken, replaceCanvasState, resetHistory]);
const saveProject = useCallback(async () => {
if (!project || !accessToken) return;
setSaving(true);
try {
const res = await fetch(`/api/canvas/projects/${encodeURIComponent(project.id)}`, {
method: 'PUT',
headers: getAuthHeaders(accessToken),
body: JSON.stringify({ title: project.title, state }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '保存失败');
const nextProject = data.project as CanvasProject;
setProject(nextProject);
setProjects(prev => prev.map(item => item.id === nextProject.id ? nextProject : item));
setDirty(false);
toast.success('画布已保存');
} catch (error) {
toast.error(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
}, [accessToken, project, state]);
const deleteCurrentProject = useCallback(async () => {
if (!project || !accessToken) return;
const ok = window.confirm(`确定删除画布“${project.title}”吗?此操作不能撤销。`);
if (!ok) return;
try {
const res = await fetch(`/api/canvas/projects/${encodeURIComponent(project.id)}`, {
method: 'DELETE',
headers: getAuthHeaders(accessToken),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '删除失败');
const remaining = projects.filter(item => item.id !== project.id);
setProjects(remaining);
const nextProject = remaining[0] || null;
setProject(nextProject);
replaceCanvasState(nextProject ? { ...nextProject.state, connections: nextProject.state.connections || [] } : { nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } });
resetHistory();
setSelectedNodeId(null);
setSelectedNodeIds([]);
setDirty(false);
if (nextProject) {
window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
} else {
window.localStorage.removeItem(LOCAL_DRAFT_KEY);
}
toast.success('画布已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败');
}
}, [accessToken, project, projects, replaceCanvasState, resetHistory]);
const deleteProject = useCallback(async (target: CanvasProject) => {
if (!accessToken) return;
const ok = window.confirm(`确定删除工作流“${target.title}”吗?此操作不能撤销。`);
if (!ok) return;
try {
const res = await fetch(`/api/canvas/projects/${encodeURIComponent(target.id)}`, {
method: 'DELETE',
headers: getAuthHeaders(accessToken),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '删除失败');
const remaining = projects.filter(item => item.id !== target.id);
setProjects(remaining);
if (project?.id !== target.id) {
toast.success('工作流已删除');
return;
}
const nextProject = remaining[0] || null;
setProject(nextProject);
replaceCanvasState(nextProject ? { ...nextProject.state, connections: nextProject.state.connections || [] } : { nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } });
resetHistory();
setSelectedNodeId(null);
setSelectedNodeIds([]);
setDirty(false);
if (nextProject) {
window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
} else {
window.localStorage.removeItem(LOCAL_DRAFT_KEY);
}
toast.success('工作流已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败');
}
}, [accessToken, project, projects, replaceCanvasState, resetHistory]);
const exportProjectJson = useCallback(() => {
if (!project) return;
const safeTitle = project.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'canvas';
downloadJson({ version: 1, title: project.title, state }, `${safeTitle}.miaojing-canvas.json`);
}, [project, state]);
const importProjectJson = useCallback(async (file: File) => {
try {
const text = await file.text();
const parsed = JSON.parse(text) as { title?: unknown; state?: unknown; nodes?: unknown };
const importedState = parsed.state || parsed;
const nextState = {
...(importedState as CanvasProjectState),
connections: Array.isArray((importedState as CanvasProjectState).connections) ? (importedState as CanvasProjectState).connections : [],
nodes: Array.isArray((importedState as CanvasProjectState).nodes) ? (importedState as CanvasProjectState).nodes : [],
assets: Array.isArray((importedState as CanvasProjectState).assets) ? (importedState as CanvasProjectState).assets : [],
viewport: (importedState as CanvasProjectState).viewport || { x: 0, y: 0, zoom: 1 },
};
replaceCanvasState(nextState, { dirty: true });
resetHistory();
if (project && typeof parsed.title === 'string' && parsed.title.trim()) {
setProject({ ...project, title: parsed.title.trim() });
}
setSelectedNodeId(null);
setSelectedNodeIds([]);
toast.success('画布已导入');
} catch (error) {
toast.error(error instanceof Error ? error.message : '导入失败');
}
}, [project, replaceCanvasState, resetHistory]);
const handleImportFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
void importProjectJson(file);
}, [importProjectJson]);
useEffect(() => {
if (!dirty || !project || !accessToken) return;
const timer = window.setTimeout(() => {
void saveProject();
}, 2500);
return () => window.clearTimeout(timer);
}, [accessToken, dirty, project, saveProject]);
const updateNode = useCallback((id: string, patch: Partial<CanvasNode>) => {
mutateState(current => ({
...current,
nodes: current.nodes.map(node => node.id === id ? { ...node, ...patch, updatedAt: nowIso() } : node),
}));
}, [mutateState]);
const updateLayer = useCallback((nodeId: string, layerId: string, patch: Partial<CanvasLayer>) => {
mutateState(current => ({
...current,
nodes: current.nodes.map(node => {
if (node.id !== nodeId) return node;
return {
...node,
updatedAt: nowIso(),
layers: (node.layers || []).map(layer => layer.id === layerId ? { ...layer, ...patch } : layer),
};
}),
}));
}, [mutateState]);
const addLayer = useCallback((nodeId: string, type: CanvasLayer['type']) => {
const layer: CanvasLayer = {
id: createId('layer'),
name: type === 'background' ? '背景层' : type === 'text' ? '文字层' : type === 'icon' ? '图标层' : type === 'effect' ? '效果层' : '元素层',
type,
visible: true,
locked: false,
color: LAYER_COLORS[type],
opacity: type === 'background' ? 1 : 0.9,
text: type === 'text' ? '双击编辑文字' : undefined,
x: type === 'background' ? 0 : 180,
y: type === 'background' ? 0 : 180,
width: type === 'background' ? LAYER_CANVAS_SIZE : type === 'text' ? 720 : 360,
height: type === 'background' ? LAYER_CANVAS_SIZE : type === 'text' ? 120 : 320,
};
mutateState(current => ({
...current,
nodes: current.nodes.map(node => node.id === nodeId ? { ...node, layers: [...(node.layers || []), layer], updatedAt: nowIso() } : node),
}));
}, [mutateState]);
const removeLayer = useCallback((nodeId: string, layerId: string) => {
mutateState(current => ({
...current,
nodes: current.nodes.map(node => node.id === nodeId ? { ...node, layers: (node.layers || []).filter(layer => layer.id !== layerId), updatedAt: nowIso() } : node),
}));
}, [mutateState]);
const moveLayer = useCallback((nodeId: string, layerId: string, direction: -1 | 1) => {
mutateState(current => ({
...current,
nodes: current.nodes.map(node => {
if (node.id !== nodeId) return node;
const layers = [...(node.layers || [])];
const index = layers.findIndex(layer => layer.id === layerId);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= layers.length) return node;
const [layer] = layers.splice(index, 1);
layers.splice(nextIndex, 0, layer);
return { ...node, layers, updatedAt: nowIso() };
}),
}));
}, [mutateState]);
const addNode = useCallback((type: CanvasNodeType, x: number, y: number, patch: Partial<CanvasNode> = {}) => {
const zIndex = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1;
const node = createNode(type, x, y, zIndex, patch);
mutateState(current => ({ ...current, nodes: [...current.nodes, node] }));
setSelectedNodeId(node.id);
setSelectedNodeIds([node.id]);
setAddMenu(null);
return node;
}, [mutateState, state.nodes]);
const addWorkflowTemplate = useCallback((type: WorkflowTemplateType, origin?: { x: number; y: number }) => {
const startX = origin?.x ?? 120;
const startY = origin?.y ?? 120;
const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0);
const createdAt = nowIso();
const nodes: CanvasNode[] = [];
const connections: CanvasConnection[] = [];
const pushNode = (nodeType: CanvasNodeType, x: number, y: number, patch: Partial<CanvasNode> = {}) => {
const node = createNode(nodeType, x, y, baseZ + nodes.length + 1, patch);
nodes.push(node);
return node;
};
const connect = (source: CanvasNode, target: CanvasNode, label?: string) => {
connections.push({
id: createId('connection'),
sourceNodeId: source.id,
targetNodeId: target.id,
label,
createdAt,
});
};
if (type === 'text2img') {
const prompt = pushNode('text', startX, startY, { title: '提示词草稿', text: '在这里写画面主体、风格、光线、构图和细节要求。' });
const generator = pushNode('text2img', startX + 380, startY, { title: '文生图生成', prompt: '高质量商业视觉,细节丰富,构图清晰' });
const result = pushNode('image', startX + 800, startY, { title: '结果图', width: 380, height: 420 });
connect(prompt, generator, '提示词');
connect(generator, result, '生成结果');
} else if (type === 'img2img') {
const reference = pushNode('image', startX, startY, { title: '参考图', width: 360, height: 360 });
const prompt = pushNode('text', startX, startY + 420, { title: '改图要求', text: '描述保留哪些内容,以及需要替换的风格、背景、元素和细节。' });
const generator = pushNode('img2img', startX + 420, startY + 120, { title: '图生图生成', prompt: '保留主体结构,提升质感和细节' });
const result = pushNode('image', startX + 840, startY + 120, { title: '改图结果', width: 380, height: 420 });
connect(reference, generator, '参考图');
connect(prompt, generator, '提示词');
connect(generator, result, '生成结果');
} else if (type === 'image-chain') {
const prompt = pushNode('text', startX, startY, { title: '创意方向', text: '先生成主视觉,再把结果作为参考继续细化。' });
const text2img = pushNode('text2img', startX + 380, startY, { title: '第一轮文生图', prompt: '主视觉概念图,清晰主体,完整构图' });
const first = pushNode('image', startX + 800, startY, { title: '第一轮结果', width: 360, height: 380 });
const img2img = pushNode('img2img', startX + 1220, startY, { title: '第二轮精修', prompt: '在保留构图基础上增强质感、材质、光影和细节' });
const final = pushNode('image', startX + 1640, startY, { title: '最终结果', width: 380, height: 420 });
connect(prompt, text2img, '提示词');
connect(text2img, first, '生成结果');
connect(first, img2img, '参考图');
connect(img2img, final, '生成结果');
} else {
const prompt = pushNode('text', startX, startY, { title: '图层设计说明', text: '描述背景、主体元素、图标、文字和效果层的编辑要求。' });
const generator = pushNode('text2img', startX + 380, startY, { title: '视觉生成', prompt: '可拆分图层的海报设计,背景、主体、图标和文字层次清晰' });
const layered = pushNode('layeredImage', startX + 800, startY, { title: '图层整理与 PSD 导出', width: 460, height: 500 });
connect(prompt, generator, '提示词');
connect(generator, layered, '图层整理');
}
mutateState(current => ({
...current,
nodes: [...current.nodes, ...nodes],
connections: [...current.connections, ...connections],
}));
setSelectedNodeId(nodes[0]?.id || null);
setSelectedNodeIds(nodes.map(node => node.id));
setAddMenu(null);
toast.success('已添加工作流模板');
}, [mutateState, state.nodes]);
const removeSelectedNode = useCallback(() => {
const ids = selectedNodeIds.length > 0 ? selectedNodeIds : selectedNodeId ? [selectedNodeId] : [];
if (ids.length === 0) return;
const idSet = new Set(ids);
mutateState(current => ({
...current,
nodes: current.nodes.filter(node => !idSet.has(node.id)),
connections: current.connections.filter(
connection => !idSet.has(connection.sourceNodeId) && !idSet.has(connection.targetNodeId),
),
}));
setConnectingFromId(current => current && idSet.has(current) ? null : current);
setSelectedNodeId(null);
setSelectedNodeIds([]);
}, [mutateState, selectedNodeId, selectedNodeIds]);
const duplicateSelectedNode = useCallback(() => {
const sourceNodes = selectedNodes.length > 0 ? selectedNodes : selectedNode ? [selectedNode] : [];
if (sourceNodes.length === 0) return;
const createdAt = nowIso();
const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0);
const duplicates = sourceNodes.map((source, index): CanvasNode => ({
...source,
id: createId(source.type),
title: `${source.title} 副本`,
x: source.x + 32,
y: source.y + 32,
zIndex: baseZ + index + 1,
createdAt,
updatedAt: createdAt,
layers: source.layers?.map(layer => ({ ...layer, id: createId('layer') })),
}));
mutateState(current => ({ ...current, nodes: [...current.nodes, ...duplicates] }));
setSelectedNodeId(duplicates[0]?.id || null);
setSelectedNodeIds(duplicates.map(node => node.id));
}, [mutateState, selectedNode, selectedNodes, state.nodes]);
const addConnection = useCallback((sourceNodeId: string, targetNodeId: string, options?: { silent?: boolean }) => {
if (sourceNodeId === targetNodeId) {
toast.info('不能连接到同一个模块');
return;
}
mutateState(current => {
const exists = current.connections.some(
connection => connection.sourceNodeId === sourceNodeId && connection.targetNodeId === targetNodeId,
);
if (exists) return current;
const connection: CanvasConnection = {
id: createId('connection'),
sourceNodeId,
targetNodeId,
createdAt: nowIso(),
};
return { ...current, connections: [...current.connections, connection] };
});
const source = state.nodes.find(node => node.id === sourceNodeId);
const target = state.nodes.find(node => node.id === targetNodeId);
if (source?.type === 'image' && source.imageUrl && target?.type === 'img2img') {
updateNode(target.id, { referenceImage: source.imageUrl });
if (!options?.silent) toast.success('已连接,并将图片设为图生图参考');
} else if (!options?.silent) {
toast.success('已连接模块');
}
}, [mutateState, state.nodes, updateNode]);
const removeConnection = useCallback((connectionId: string) => {
mutateState(current => ({
...current,
connections: current.connections.filter(connection => connection.id !== connectionId),
}));
}, [mutateState]);
const alignSelectedNodes = useCallback((mode: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
if (!selectedBounds || selectedNodes.length < 2) return;
const idSet = new Set(selectedNodes.map(node => node.id));
mutateState(current => ({
...current,
nodes: current.nodes.map(node => {
if (!idSet.has(node.id)) return node;
if (mode === 'left') return { ...node, x: selectedBounds.left, updatedAt: nowIso() };
if (mode === 'center') return { ...node, x: selectedBounds.left + selectedBounds.width / 2 - node.width / 2, updatedAt: nowIso() };
if (mode === 'right') return { ...node, x: selectedBounds.right - node.width, updatedAt: nowIso() };
if (mode === 'top') return { ...node, y: selectedBounds.top, updatedAt: nowIso() };
if (mode === 'middle') return { ...node, y: selectedBounds.top + selectedBounds.height / 2 - node.height / 2, updatedAt: nowIso() };
return { ...node, y: selectedBounds.bottom - node.height, updatedAt: nowIso() };
}),
}));
}, [mutateState, selectedBounds, selectedNodes]);
const distributeSelectedNodes = useCallback((axis: 'x' | 'y') => {
if (selectedNodes.length < 3) return;
const sorted = [...selectedNodes].sort((a, b) => axis === 'x' ? a.x - b.x : a.y - b.y);
const first = sorted[0];
const last = sorted[sorted.length - 1];
if (!first || !last) return;
const start = axis === 'x' ? first.x : first.y;
const end = axis === 'x' ? last.x : last.y;
const step = (end - start) / (sorted.length - 1);
const positions = new Map(sorted.map((node, index) => [node.id, start + step * index]));
mutateState(current => ({
...current,
nodes: current.nodes.map(node => {
const value = positions.get(node.id);
if (typeof value !== 'number') return node;
return axis === 'x' ? { ...node, x: value, updatedAt: nowIso() } : { ...node, y: value, updatedAt: nowIso() };
}),
}));
}, [mutateState, selectedNodes]);
const createFrameAroundSelection = useCallback(() => {
const targetNodes = selectedNodes.filter(node => node.type !== 'frame');
const bounds = getCanvasBounds(targetNodes);
if (!bounds) {
toast.info('先选择需要整理到分组框里的模块');
return;
}
const padding = 48;
const frame = addNode('frame', bounds.left - padding, bounds.top - padding, {
title: '分组框',
width: bounds.width + padding * 2,
height: bounds.height + padding * 2,
color: '#22c55e',
text: '流程分组',
zIndex: 0,
});
mutateState(current => ({
...current,
nodes: current.nodes.map(node => node.id === frame.id ? { ...node, zIndex: 0 } : node),
}));
setSelectedNodeIds([frame.id, ...targetNodes.map(node => node.id)]);
setSelectedNodeId(frame.id);
}, [addNode, mutateState, selectedNodes]);
const handleConnectorClick = useCallback((nodeId: string, role: 'input' | 'output') => {
if (role === 'output') {
setConnectingFromId(current => current === nodeId ? null : nodeId);
setSelectedNodeId(nodeId);
setSelectedNodeIds([nodeId]);
return;
}
if (!connectingFromId) {
toast.info('先点击一个模块右侧的输出端口');
return;
}
addConnection(connectingFromId, nodeId);
setConnectingFromId(null);
setSelectedNodeId(nodeId);
setSelectedNodeIds([nodeId]);
}, [addConnection, connectingFromId]);
const addImageAsset = useCallback(async (file: File, point?: { x: number; y: number }) => {
const dataUrl = await fileToDataUrl(file);
const dimensions = await getImageDimensions(dataUrl);
const asset: CanvasAsset = {
id: createId('asset'),
url: dataUrl,
name: file.name || '上传图片',
type: 'image',
createdAt: nowIso(),
};
mutateState(current => ({ ...current, assets: [asset, ...current.assets] }));
addNode('image', point?.x ?? 120, point?.y ?? 120, {
title: file.name || '图片',
imageUrl: dataUrl,
width: Math.min(520, Math.max(260, (dimensions.width || 600) / 2)),
height: Math.min(520, Math.max(240, (dimensions.height || 420) / 2 + 80)),
});
}, [addNode, mutateState]);
const renderSingleLayerToCanvas = useCallback((layer: CanvasLayer) => {
const canvas = document.createElement('canvas');
canvas.width = LAYER_CANVAS_SIZE;
canvas.height = LAYER_CANVAS_SIZE;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('当前浏览器不支持 PSD 图层导出');
drawLayerPreview(ctx, { ...layer, visible: true }, 1);
return canvas;
}, []);
const exportLayeredNode = useCallback(async (node: CanvasNode, format: 'png' | 'json' | 'psd') => {
try {
const safeTitle = node.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'layered-image';
if (format === 'json') {
downloadBlob(
new Blob([JSON.stringify({ version: 1, title: node.title, layers: node.layers || [] }, null, 2)], { type: 'application/json' }),
`${safeTitle}.layers.json`,
);
return;
}
const canvas = renderLayeredNodeToCanvas(node);
if (format === 'png') {
canvas.toBlob(blob => {
if (blob) downloadBlob(blob, `${safeTitle}.png`);
}, 'image/png');
return;
}
const { writePsd } = await import('ag-psd');
type AgPsdLayer = {
name: string;
top: number;
left: number;
bottom: number;
right: number;
hidden?: boolean;
opacity?: number;
canvas?: HTMLCanvasElement;
text?: {
text: string;
transform: [number, number, number, number, number, number];
style: {
font: { name: string };
fontSize: number;
fillColor: { r: number; g: number; b: number };
};
};
};
const children: AgPsdLayer[] = (node.layers || []).map(layer => {
const top = Math.max(0, Math.round(layer.y));
const left = Math.max(0, Math.round(layer.x));
const bottom = Math.min(LAYER_CANVAS_SIZE, Math.round(layer.y + layer.height));
const right = Math.min(LAYER_CANVAS_SIZE, Math.round(layer.x + layer.width));
const canvasLayer: AgPsdLayer = {
name: layer.name,
top,
left,
bottom,
right,
hidden: !layer.visible,
opacity: Math.round((layer.opacity ?? 1) * 255),
canvas: renderSingleLayerToCanvas(layer),
};
if (layer.type === 'text') {
const color = layer.color || LAYER_COLORS.text;
const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
canvasLayer.text = {
text: layer.text || layer.name,
transform: [1, 0, 0, 1, layer.x + 12, layer.y + Math.max(18, Math.min(72, layer.height * 0.45))],
style: {
font: { name: 'ArialMT' },
fontSize: Math.max(18, Math.min(72, layer.height * 0.45)),
fillColor: rgb
? { r: parseInt(rgb[1], 16), g: parseInt(rgb[2], 16), b: parseInt(rgb[3], 16) }
: { r: 17, g: 24, b: 39 },
},
};
}
return canvasLayer;
});
const buffer = writePsd({
width: LAYER_CANVAS_SIZE,
height: LAYER_CANVAS_SIZE,
children,
canvas,
}, { noBackground: true, invalidateTextLayers: true, generateThumbnail: true });
downloadBlob(new Blob([buffer], { type: 'image/vnd.adobe.photoshop' }), `${safeTitle}.psd`);
} catch (error) {
toast.error(error instanceof Error ? error.message : '导出失败');
}
}, [renderSingleLayerToCanvas]);
const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('请上传图片文件');
return;
}
void addImageAsset(file, { x: 160, y: 160 });
}, [addImageAsset]);
const handlePasteImage = useCallback((event: ClipboardEvent) => {
if (isEditableTarget(event.target)) return;
const file = Array.from(event.clipboardData?.files || []).find(item => item.type.startsWith('image/'));
if (!file) return;
event.preventDefault();
void addImageAsset(file, screenToCanvasPoint(window.innerWidth / 2, window.innerHeight / 2));
}, [addImageAsset, screenToCanvasPoint]);
useEffect(() => {
window.addEventListener('paste', handlePasteImage);
return () => window.removeEventListener('paste', handlePasteImage);
}, [handlePasteImage]);
const buildGenerationPayload = useCallback((node: CanvasNode, canvasState: CanvasProjectState = state) => {
const incomingNodes = canvasState.connections
.filter(connection => connection.targetNodeId === node.id)
.map(connection => canvasState.nodes.find(item => item.id === connection.sourceNodeId))
.filter((item): item is CanvasNode => !!item);
const incomingPromptText = incomingNodes.map(getNodeTextValue).filter(Boolean).join('\n');
const incomingImageNode = incomingNodes.find(item => !!getNodeImageUrl(item));
const referenceImage = node.referenceImage || getNodeImageUrl(incomingImageNode);
const basePrompt = node.prompt?.trim() || '';
const prompt = incomingPromptText
? [incomingPromptText, basePrompt].filter(Boolean).join('\n\n')
: basePrompt;
const selectedModel = node.params?.model || modelOptions[0]?.id || '';
const aspectRatio = node.params?.aspectRatio || (node.type === 'img2img' ? 'original' : '1:1');
const resolution = node.params?.resolution || '2K';
const count = node.params?.count || 1;
const isExternal = isCustomModel(selectedModel) || isSystemModel(selectedModel);
const payload: Record<string, unknown> = {
prompt,
negativePrompt: node.negativePrompt?.trim() || undefined,
model: selectedModel,
aspectRatio,
resolution,
quality: resolution,
count,
};
if (node.type === 'img2img') {
payload.image = referenceImage;
payload.strength = node.params?.strength ?? 0.5;
payload.size = aspectRatio === 'original'
? resolveImageSizeFromDimensions(undefined, undefined, resolution)
: isExternal
? resolveCustomApiImageSize(aspectRatio, resolution)
: resolveImageSize(aspectRatio, resolution);
} else {
payload.size = isExternal
? resolveCustomApiImageSize(aspectRatio, resolution)
: resolveImageSize(aspectRatio, resolution);
}
if (isCustomModel(selectedModel)) {
const key = imageKeys.find(item => item.id === getCustomKeyId(selectedModel));
if (key) {
payload.model = key.modelName;
payload.customApiConfig = { customApiKeyId: key.id, modelName: key.modelName };
}
} else if (isSystemModel(selectedModel)) {
const api = systemImageApis.find(item => item.id === getSystemApiId(selectedModel));
if (api) {
payload.model = api.modelName;
payload.customApiConfig = { systemApiId: api.id, modelName: api.modelName };
}
}
return payload;
}, [imageKeys, modelOptions, state, systemImageApis]);
const generateForNode = useCallback(async (node: CanvasNode, options?: { canvasState?: CanvasProjectState; selectOutput?: boolean }) => {
const canvasState = options?.canvasState || state;
if (!user) {
toast.error('请先登录后再生成');
return null;
}
if (!node.prompt?.trim()) {
const hasIncomingText = canvasState.connections
.filter(connection => connection.targetNodeId === node.id)
.some(connection => {
const source = canvasState.nodes.find(item => item.id === connection.sourceNodeId);
return !!getNodeTextValue(source);
});
if (!hasIncomingText) {
toast.error('请输入创作描述,或连接文本模块作为提示词');
return null;
}
}
const hasIncomingImage = canvasState.connections
.filter(connection => connection.targetNodeId === node.id)
.some(connection => {
const source = canvasState.nodes.find(item => item.id === connection.sourceNodeId);
return !!getNodeImageUrl(source);
});
if (node.type === 'img2img' && !node.referenceImage && !hasIncomingImage) {
toast.error('图生图模块需要先选择参考图');
return null;
}
updateNode(node.id, { status: 'generating', error: undefined, outputImages: [] });
try {
const payload = buildGenerationPayload(node, canvasState);
const normalizedModel = String(payload.model || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const timeoutMs = normalizedModel === 'image2' || normalizedModel === 'gptimage2' ? 1_200_000 : 300_000;
const data = await runGenerationJob<{ images?: string[]; referenceImage?: string; apiType?: 'stream' | 'sync'; error?: string }>(
'image',
payload,
{ timeoutMs },
);
if (!data.images || data.images.length === 0) {
throw new Error(data.error || '生成结果为空');
}
const images = data.images;
const apiType = data.apiType === 'stream' ? 'stream' : 'sync';
const updatedAt = nowIso();
const sourcePatch: Partial<CanvasNode> = {
status: 'succeeded',
outputImages: images,
selectedOutput: images[0],
referenceImage: typeof data.referenceImage === 'string' ? data.referenceImage : node.referenceImage,
params: { ...node.params, apiType },
};
const existingOutputNode = canvasState.connections
.filter(connection => connection.sourceNodeId === node.id)
.map(connection => canvasState.nodes.find(item => item.id === connection.targetNodeId))
.find((item): item is CanvasNode => item?.type === 'image');
const generatedOutputNode = existingOutputNode
? {
...existingOutputNode,
imageUrl: images[0],
outputImages: images,
selectedOutput: images[0],
status: 'succeeded' as const,
updatedAt,
}
: createNode('image', node.x + node.width + 40, node.y, canvasState.nodes.reduce((max, item) => Math.max(max, item.zIndex), 0) + 1, {
title: `${node.title}结果`,
imageUrl: images[0],
outputImages: images,
selectedOutput: images[0],
width: 380,
height: 420,
status: 'succeeded',
});
const outputConnection = existingOutputNode
? null
: {
id: createId('connection'),
sourceNodeId: node.id,
targetNodeId: generatedOutputNode.id,
label: '生成结果',
createdAt: updatedAt,
};
const nextCanvasState: CanvasProjectState = {
...canvasState,
nodes: canvasState.nodes
.map(item => {
if (item.id === node.id) return { ...item, ...sourcePatch, updatedAt };
if (item.id === generatedOutputNode.id) return generatedOutputNode;
return item;
})
.concat(existingOutputNode ? [] : [generatedOutputNode]),
connections: outputConnection ? [...canvasState.connections, outputConnection] : canvasState.connections,
};
mutateState(current => ({
...current,
nodes: current.nodes
.map(item => {
if (item.id === node.id) return { ...item, ...sourcePatch, updatedAt };
if (item.id === generatedOutputNode.id) return generatedOutputNode;
return item;
})
.concat(current.nodes.some(item => item.id === generatedOutputNode.id) ? [] : [generatedOutputNode]),
connections: outputConnection && !current.connections.some(item => item.sourceNodeId === outputConnection.sourceNodeId && item.targetNodeId === outputConnection.targetNodeId)
? [...current.connections, outputConnection]
: current.connections,
}));
if (options?.selectOutput !== false) {
setSelectedNodeId(generatedOutputNode.id);
setSelectedNodeIds([generatedOutputNode.id]);
}
toast.success(`已生成 ${images.length} 张图片`);
return { images, outputNodeId: generatedOutputNode.id, canvasState: nextCanvasState };
} catch (error) {
updateNode(node.id, { status: 'failed', error: error instanceof Error ? error.message : '生成失败' });
toast.error(error instanceof Error ? error.message : '生成失败');
return null;
}
}, [buildGenerationPayload, mutateState, state, updateNode, user]);
const runDownstream = useCallback(async (node: CanvasNode) => {
let workingState = state;
const visited = new Set<string>();
const queue = [node.id];
const runnable: CanvasNode[] = node.type === 'text2img' || node.type === 'img2img' ? [node] : [];
while (queue.length > 0) {
const currentId = queue.shift();
if (!currentId || visited.has(currentId)) continue;
visited.add(currentId);
const outgoing = workingState.connections.filter(connection => connection.sourceNodeId === currentId);
for (const connection of outgoing) {
const target = workingState.nodes.find(item => item.id === connection.targetNodeId);
if (!target || visited.has(target.id)) continue;
if (target.type === 'text2img' || target.type === 'img2img') {
runnable.push(target);
}
queue.push(target.id);
}
}
const uniqueRunnable = runnable.filter((item, index, array) => array.findIndex(candidate => candidate.id === item.id) === index);
if (uniqueRunnable.length === 0) {
toast.info('下游没有可执行的生图模块');
return;
}
for (const target of uniqueRunnable) {
const latest = workingState.nodes.find(item => item.id === target.id) || target;
const result = await generateForNode(latest, { canvasState: workingState, selectOutput: false });
if (!result) return;
workingState = result.canvasState;
}
const finalNodeId = uniqueRunnable[uniqueRunnable.length - 1]?.id || node.id;
setSelectedNodeId(finalNodeId);
setSelectedNodeIds([finalNodeId]);
toast.success('下游流程已执行完成');
}, [generateForNode, state]);
const handleFlowNodeSelect = useCallback((nodeId: string, additive: boolean) => {
setAddMenu(null);
setConnectingFromId(null);
setSelectedNodeId(nodeId);
setSelectedNodeIds(current => {
if (!additive) return current.includes(nodeId) ? current : [nodeId];
return current.includes(nodeId) ? current.filter(id => id !== nodeId) : [...current, nodeId];
});
}, []);
const handleFlowNodesCommit = useCallback((positions: { id: string; x: number; y: number }[], options?: { history?: boolean; dirty?: boolean }) => {
if (positions.length === 0) return;
const positionMap = new Map(positions.map(position => [position.id, position]));
mutateState(current => ({
...current,
nodes: current.nodes.map(node => {
const position = positionMap.get(node.id);
if (!position || (node.x === position.x && node.y === position.y)) return node;
return { ...node, x: position.x, y: position.y, updatedAt: nowIso() };
}),
}), { history: options?.history ?? true, dirty: options?.dirty ?? true });
}, [mutateState]);
const handleFlowConnect = useCallback((sourceId: string, targetId: string) => {
addConnection(sourceId, targetId);
setConnectingFromId(null);
setSelectedNodeId(targetId);
setSelectedNodeIds([targetId]);
}, [addConnection]);
const handleFlowSelectionChange = useCallback((ids: string[]) => {
setSelectedNodeIds(current => sameStringArray(current, ids) ? current : ids);
setSelectedNodeId(current => current === (ids[0] || null) ? current : ids[0] || null);
}, []);
const handleRemoveFlowConnections = useCallback((ids: string[]) => {
if (ids.length === 0) return;
mutateState(current => ({
...current,
connections: current.connections.filter(connection => !ids.includes(connection.id)),
}));
}, [mutateState]);
const handleFlowStartConnect = useCallback((id: string) => {
handleConnectorClick(id, 'output');
}, [handleConnectorClick]);
const handleViewportCommit = useCallback((viewport: CanvasViewport) => {
const nextViewport = {
x: viewport.x,
y: viewport.y,
zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, viewport.zoom)),
};
mutateState(current => ({ ...current, viewport: nextViewport }), { history: false, dirty: false });
}, [mutateState]);
const handlePaneClick = useCallback((point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => {
setConnectingFromId(null);
setSelectedNodeId(null);
setSelectedNodeIds([]);
setAddMenu(null);
if (event.detail !== 1) return;
setAddMenu({ x: point.x, y: point.y, screenX: event.clientX, screenY: event.clientY });
}, []);
const handlePaneDoubleClick = useCallback((point: { x: number; y: number }) => {
addNode('text2img', point.x, point.y);
}, [addNode]);
const handleCanvasDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!Array.from(event.dataTransfer?.items || []).some(item => item.kind === 'file' && item.type.startsWith('image/'))) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setIsDraggingFile(true);
}, []);
const handleCanvasDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setIsDraggingFile(false);
}, []);
const handleCanvasDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const files = Array.from(event.dataTransfer?.files || []).filter(file => file.type.startsWith('image/'));
if (files.length === 0) return;
event.preventDefault();
setIsDraggingFile(false);
const point = screenToCanvasPoint(event.clientX, event.clientY);
files.slice(0, 6).forEach((file, index) => {
void addImageAsset(file, { x: point.x + index * 28, y: point.y + index * 28 });
});
}, [addImageAsset, screenToCanvasPoint]);
const setZoom = useCallback((zoom: number) => {
const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
const controls = flowControlsRef.current;
if (controls) {
void controls.zoomTo(nextZoom).then(() => {
handleViewportCommit(controls.getViewport());
});
return;
}
mutateState(current => ({
...current,
viewport: { ...current.viewport, zoom: nextZoom },
}));
}, [handleViewportCommit, mutateState]);
const undoCanvas = useCallback(() => {
const previous = historyRef.current.past.pop();
if (!previous) return;
historyRef.current.future = [state, ...historyRef.current.future.slice(0, 49)];
replaceCanvasState(previous, { dirty: true });
setSelectedNodeId(null);
setSelectedNodeIds([]);
}, [replaceCanvasState, state]);
const redoCanvas = useCallback(() => {
const next = historyRef.current.future.shift();
if (!next) return;
historyRef.current.past = [...historyRef.current.past.slice(-49), state];
replaceCanvasState(next, { dirty: true });
setSelectedNodeId(null);
setSelectedNodeIds([]);
}, [replaceCanvasState, state]);
const resetView = useCallback(() => {
const nextViewport = { x: 0, y: 0, zoom: 1 };
const controls = flowControlsRef.current;
if (controls) {
void controls.setViewport(nextViewport).then(() => {
handleViewportCommit(controls.getViewport());
});
return;
}
mutateState(current => ({
...current,
viewport: nextViewport,
}));
}, [handleViewportCommit, mutateState]);
const fitToContent = useCallback(() => {
const bounds = getCanvasBounds(state.nodes);
const rect = canvasRef.current?.getBoundingClientRect();
if (!bounds || !rect) {
resetView();
return;
}
const padding = 96;
const zoom = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, Math.min((rect.width - padding * 2) / Math.max(bounds.width, 1), (rect.height - padding * 2) / Math.max(bounds.height, 1))),
);
const fitBounds = {
x: bounds.left - padding,
y: bounds.top - padding,
width: bounds.width + padding * 2,
height: bounds.height + padding * 2,
};
const controls = flowControlsRef.current;
if (controls) {
void controls.fitBounds(fitBounds, { padding: 0, duration: 120 }).then(() => {
handleViewportCommit(controls.getViewport());
});
return;
}
mutateState(current => ({
...current,
viewport: {
zoom,
x: rect.width / 2 - (bounds.left + bounds.width / 2) * zoom,
y: rect.height / 2 - (bounds.top + bounds.height / 2) * zoom,
},
}));
}, [handleViewportCommit, mutateState, resetView, state.nodes]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
void saveProject();
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && event.shiftKey) {
event.preventDefault();
redoCanvas();
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
undoCanvas();
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
event.preventDefault();
redoCanvas();
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') {
event.preventDefault();
duplicateSelectedNode();
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
event.preventDefault();
const ids = state.nodes.map(node => node.id);
setSelectedNodeIds(ids);
setSelectedNodeId(ids[0] || null);
} else if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
removeSelectedNode();
} else if (event.key === 'Escape') {
setAddMenu(null);
setConnectingFromId(null);
setSelectedNodeIds([]);
setSelectedNodeId(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [duplicateSelectedNode, redoCanvas, removeSelectedNode, saveProject, state.nodes, undoCanvas]);
const activeModelValue = selectedNode?.params?.model || modelOptions[0]?.id || '';
if (!mounted) {
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center bg-background px-4">
<div className="max-w-md rounded-xl border bg-card p-6 text-center shadow-sm">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
<p className="mt-3 text-sm text-muted-foreground"></p>
</div>
</div>
);
}
if (!user) {
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center bg-background px-4">
<div className="max-w-md rounded-xl border bg-card p-6 text-center shadow-sm">
<Sparkles className="mx-auto h-10 w-10 text-primary" />
<h1 className="mt-4 text-xl font-semibold">使</h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
</div>
);
}
return (
<div className="flex h-[calc(100vh-4rem)] min-h-[720px] overflow-hidden bg-background">
<aside className="hidden w-72 shrink-0 flex-col overflow-hidden border-r bg-muted/20 lg:flex">
<div className="flex shrink-0 items-center justify-between gap-3 p-4 pb-3">
<div>
<h1 className="text-lg font-semibold"></h1>
<p className="text-xs text-muted-foreground"> AI </p>
</div>
<Button size="icon" onClick={createProject} disabled={creatingProject} title="新建画布">
{creatingProject ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<div className="space-y-2">
<div className="text-xs font-semibold uppercase text-muted-foreground"></div>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : projects.length === 0 ? (
<Button className="w-full justify-start gap-2" variant="outline" onClick={createProject}>
<Plus className="h-4 w-4" />
</Button>
) : (
projects.map(item => (
<div
key={item.id}
className={cn(
'group flex w-full items-start gap-2 rounded-lg border bg-background p-2 text-sm transition-colors',
project?.id === item.id ? 'border-primary bg-primary/10 text-primary' : 'bg-background hover:bg-muted/60',
)}
>
<button className="min-w-0 flex-1 text-left" onClick={() => void openProject(item.id)}>
<div className="truncate font-medium">{item.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.state.nodes.length} · {(item.state.connections || []).length} 线</div>
</button>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
title="删除工作流"
onClick={(event) => {
event.stopPropagation();
void deleteProject(item);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
<div className="mt-6">
<div className="mb-2 text-xs font-semibold uppercase text-muted-foreground"></div>
<div className="grid grid-cols-2 gap-2">
<Button size="sm" variant="outline" className="justify-start gap-2" onClick={() => addWorkflowTemplate('text2img', getViewportCenterPoint())} disabled={!project}>
<Brush className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" className="justify-start gap-2" onClick={() => addWorkflowTemplate('img2img', getViewportCenterPoint())} disabled={!project}>
<FileImage className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" className="justify-start gap-2" onClick={() => addWorkflowTemplate('image-chain', getViewportCenterPoint())} disabled={!project}>
<Wand2 className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" className="justify-start gap-2" onClick={() => addWorkflowTemplate('layered', getViewportCenterPoint())} disabled={!project}>
<Layers className="h-4 w-4" /> PSD
</Button>
</div>
</div>
<div className="mt-6">
<div className="mb-2 text-xs font-semibold uppercase text-muted-foreground"></div>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
<input ref={importInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleImportFileChange} />
<Button className="w-full justify-start gap-2" variant="outline" onClick={() => fileInputRef.current?.click()}>
<Upload className="h-4 w-4" />
</Button>
<div className="mt-3 grid grid-cols-2 gap-2">
{state.assets.slice(0, 8).map(asset => (
<button
key={asset.id}
className="aspect-square overflow-hidden rounded-lg border bg-background"
onClick={() => {
const point = getViewportCenterPoint();
addNode('image', point.x, point.y, { title: asset.name, imageUrl: asset.url });
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={asset.url} alt={asset.name} className="h-full w-full object-cover" />
</button>
))}
</div>
</div>
</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b bg-background px-3">
<div className="flex min-w-0 items-center gap-2">
<Button variant="outline" size="sm" className="gap-2 lg:hidden" onClick={createProject}>
<Plus className="h-4 w-4" />
</Button>
<Input
className="h-9 w-56 border-transparent bg-muted/50 font-medium focus-visible:border-border md:w-80"
value={project?.title || ''}
placeholder="未命名画布"
onChange={(event) => {
if (!project) return;
setProject({ ...project, title: event.target.value });
setDirty(true);
}}
disabled={!project}
/>
{dirty ? <Badge variant="outline"></Badge> : <Badge variant="secondary"></Badge>}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={undoCanvas} title="撤销">
<Undo2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={redoCanvas} title="重做">
<Redo2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={fitToContent} title="适应内容">
<Maximize2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={resetView} title="重置视图">
<RotateCcw className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setZoom(state.viewport.zoom - 0.1)} title="缩小">
<ZoomOut className="h-4 w-4" />
</Button>
<span className="w-12 text-center text-xs text-muted-foreground">{Math.round(state.viewport.zoom * 100)}%</span>
<Button variant="outline" size="icon" onClick={() => setZoom(state.viewport.zoom + 0.1)} title="放大">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={() => void saveProject()} disabled={!project || saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={exportProjectJson} disabled={!project}>
<Download className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={() => importInputRef.current?.click()} disabled={!project}>
<Upload className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="text-destructive" onClick={() => void deleteCurrentProject()} disabled={!project} title="删除画布">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div
ref={canvasRef}
className="relative flex-1 overflow-hidden bg-[linear-gradient(135deg,hsl(var(--background))_0%,hsl(var(--muted))_55%,hsl(var(--background))_100%)]"
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
>
<ReactFlowCanvas
nodes={state.nodes}
connections={state.connections}
viewport={state.viewport}
selectedNodeIds={selectedNodeIds}
connectingFromId={connectingFromId}
editable={!!project}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
layerCanvasSize={LAYER_CANVAS_SIZE}
layerColors={LAYER_COLORS}
onSelectNode={handleFlowNodeSelect}
onSelectionChange={handleFlowSelectionChange}
onStartConnect={handleFlowStartConnect}
onConnect={handleFlowConnect}
onRemoveConnection={removeConnection}
onRemoveConnections={handleRemoveFlowConnections}
onNodesCommit={handleFlowNodesCommit}
onViewportCommit={handleViewportCommit}
onReady={(controls) => {
flowControlsRef.current = controls;
}}
onPaneClick={handlePaneClick}
onPaneDoubleClick={handlePaneDoubleClick}
/>
{isDraggingFile ? (
<div className="pointer-events-none absolute inset-4 z-40 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-primary/10 text-sm font-medium text-primary backdrop-blur-sm">
</div>
) : null}
<div className="pointer-events-none absolute left-6 top-6 z-20 rounded-lg border bg-background/70 px-3 py-2 text-xs text-muted-foreground shadow-sm backdrop-blur">
· · Ctrl+Z · Ctrl+D · Delete
</div>
{!project ? (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-background/40 backdrop-blur-[1px]">
<Button className="gap-2" onClick={createProject} disabled={creatingProject}>
{creatingProject ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
) : null}
<div className="absolute bottom-4 left-1/2 z-20 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-1 overflow-x-auto rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur" data-canvas-menu>
<Button size="sm" variant="ghost" className="gap-2" onClick={() => {
setSelectedNodeId(null);
setSelectedNodeIds([]);
}}>
<MousePointer2 className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="gap-2" onClick={() => {
const point = getViewportCenterPoint();
addNode('text', point.x, point.y);
}}>
<Type className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="gap-2" onClick={() => fileInputRef.current?.click()}>
<Upload className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="gap-2" onClick={() => {
const point = getViewportCenterPoint();
addNode('text2img', point.x, point.y);
}}>
<Brush className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="gap-2" onClick={() => {
const point = getViewportCenterPoint();
addNode('img2img', point.x, point.y);
}}>
<FileImage className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="gap-2" onClick={() => {
const point = getViewportCenterPoint();
addNode('layeredImage', point.x, point.y);
}}>
<Layers className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="gap-2" onClick={() => {
const point = getViewportCenterPoint();
addNode('frame', point.x, point.y);
}}>
<Maximize2 className="h-4 w-4" />
</Button>
{selectedNodes.length > 1 ? (
<>
<Button size="sm" variant="ghost" className="gap-2" onClick={createFrameAroundSelection}>
<Maximize2 className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => distributeSelectedNodes('x')} disabled={selectedNodes.length < 3}>
</Button>
</>
) : null}
</div>
{addMenu ? (
<div
data-canvas-menu
className="absolute z-50 w-56 rounded-lg border bg-popover p-1 shadow-lg"
style={{ left: addMenu.screenX, top: addMenu.screenY }}
>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('text', addMenu.x, addMenu.y)}>
<Type className="h-4 w-4" />
</button>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('text2img', addMenu.x, addMenu.y)}>
<Brush className="h-4 w-4" />
</button>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('img2img', addMenu.x, addMenu.y)}>
<FileImage className="h-4 w-4" />
</button>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => fileInputRef.current?.click()}>
<ImageIcon className="h-4 w-4" />
</button>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('layeredImage', addMenu.x, addMenu.y)}>
<Layers className="h-4 w-4" />
</button>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('frame', addMenu.x, addMenu.y)}>
<Maximize2 className="h-4 w-4" />
</button>
<div className="my-1 h-px bg-border" />
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('text2img', { x: addMenu.x, y: addMenu.y })}>
<Brush className="h-4 w-4" />
</button>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('img2img', { x: addMenu.x, y: addMenu.y })}>
<FileImage className="h-4 w-4" />
</button>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('image-chain', { x: addMenu.x, y: addMenu.y })}>
<Wand2 className="h-4 w-4" />
</button>
<button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('layered', { x: addMenu.x, y: addMenu.y })}>
<Layers className="h-4 w-4" /> PSD
</button>
</div>
) : null}
</div>
</section>
<aside className="hidden w-80 shrink-0 overflow-y-auto border-l bg-background p-4 lg:block">
{selectedNodes.length > 1 ? (
<div className="space-y-4">
<div className="rounded-lg border bg-muted/20 p-3">
<div className="text-sm font-medium"> {selectedNodes.length} </div>
<div className="mt-1 text-xs text-muted-foreground">
{selectedBounds ? `${Math.round(selectedBounds.width)} × ${Math.round(selectedBounds.height)}` : '批量编辑'}
</div>
</div>
<div className="rounded-lg border p-3">
<Label></Label>
<div className="mt-2 grid grid-cols-3 gap-2">
<Button variant="outline" size="sm" onClick={() => alignSelectedNodes('left')}></Button>
<Button variant="outline" size="sm" onClick={() => alignSelectedNodes('center')}></Button>
<Button variant="outline" size="sm" onClick={() => alignSelectedNodes('right')}></Button>
<Button variant="outline" size="sm" onClick={() => alignSelectedNodes('top')}></Button>
<Button variant="outline" size="sm" onClick={() => alignSelectedNodes('middle')}>线</Button>
<Button variant="outline" size="sm" onClick={() => alignSelectedNodes('bottom')}></Button>
</div>
</div>
<div className="rounded-lg border p-3">
<Label></Label>
<div className="mt-2 grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={() => distributeSelectedNodes('x')} disabled={selectedNodes.length < 3}></Button>
<Button variant="outline" size="sm" onClick={() => distributeSelectedNodes('y')} disabled={selectedNodes.length < 3}></Button>
</div>
<Button variant="outline" className="mt-2 w-full gap-2" onClick={createFrameAroundSelection}>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" className="gap-2" onClick={duplicateSelectedNode}>
<Copy className="h-4 w-4" />
</Button>
<Button variant="destructive" className="gap-2" onClick={removeSelectedNode}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
) : !selectedNode ? (
<div className="flex h-full flex-col items-center justify-center text-center text-sm text-muted-foreground">
<StickyNote className="mb-3 h-10 w-10 opacity-40" />
</div>
) : (
<div className="space-y-4">
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Link2 className="h-4 w-4" /> 线
</div>
{connectingFromId === selectedNode.id ? <Badge variant="outline"></Badge> : null}
</div>
<div className="space-y-2 text-xs text-muted-foreground">
<Button
type="button"
variant={connectingFromId === selectedNode.id ? 'default' : 'outline'}
size="sm"
className="w-full gap-2"
onClick={() => handleConnectorClick(selectedNode.id, 'output')}
>
<Link2 className="h-4 w-4" />
{connectingFromId === selectedNode.id ? '取消连线' : '从此模块开始连线'}
</Button>
{selectedNodeConnections.length === 0 ? (
<div className="rounded-md border border-dashed p-2 text-center">线</div>
) : (
selectedNodeConnections.map(connection => {
const source = state.nodes.find(node => node.id === connection.sourceNodeId);
const target = state.nodes.find(node => node.id === connection.targetNodeId);
return (
<div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border bg-background px-2 py-1.5">
<span className="min-w-0 truncate">
{source?.title || '未知模块'} {target?.title || '未知模块'}
</span>
<button className="text-destructive hover:underline" onClick={() => removeConnection(connection.id)}></button>
</div>
);
})
)}
</div>
</div>
<div>
<div className="mb-1 flex items-center justify-between">
<Label></Label>
<div className="flex gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" onClick={duplicateSelectedNode} title="复制模块">
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7 text-destructive" onClick={removeSelectedNode} title="删除模块">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Input value={selectedNode.title} onChange={(event) => updateNode(selectedNode.id, { title: event.target.value })} />
</div>
{selectedNode.type === 'text' ? (
<div>
<Label></Label>
<Textarea className="min-h-40" value={selectedNode.text || ''} onChange={(event) => updateNode(selectedNode.id, { text: event.target.value })} />
</div>
) : null}
{selectedNode.type === 'image' ? (
<div className="space-y-2">
<Label></Label>
<Input value={selectedNode.imageUrl || ''} onChange={(event) => updateNode(selectedNode.id, { imageUrl: event.target.value })} />
{selectedNode.imageUrl ? (
<Button
variant="outline"
className="w-full gap-2"
onClick={() => addNode('img2img', selectedNode.x + selectedNode.width + 40, selectedNode.y, { referenceImage: selectedNode.imageUrl })}
>
<Wand2 className="h-4 w-4" />
</Button>
) : null}
</div>
) : null}
{selectedNode.type === 'frame' ? (
<div className="space-y-3">
<div>
<Label></Label>
<Textarea className="min-h-24" value={selectedNode.text || ''} onChange={(event) => updateNode(selectedNode.id, { text: event.target.value })} />
</div>
<div>
<Label></Label>
<Input value={selectedNode.color || '#22c55e'} onChange={(event) => updateNode(selectedNode.id, { color: event.target.value })} />
</div>
</div>
) : null}
{selectedNode.type === 'text2img' || selectedNode.type === 'img2img' ? (
<>
<div>
<Label></Label>
<Select value={activeModelValue} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, model: value } })}>
<SelectTrigger><SelectValue placeholder="选择模型" /></SelectTrigger>
<SelectContent>
{modelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Textarea className="min-h-32" value={selectedNode.prompt || ''} onChange={(event) => updateNode(selectedNode.id, { prompt: event.target.value })} />
</div>
<div>
<Label></Label>
<Textarea className="min-h-20" value={selectedNode.negativePrompt || ''} onChange={(event) => updateNode(selectedNode.id, { negativePrompt: event.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={selectedNode.params?.aspectRatio || (selectedNode.type === 'img2img' ? 'original' : '1:1')} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, aspectRatio: value } })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{(selectedNode.type === 'img2img' ? IMG2IMG_ASPECT_RATIOS : ASPECT_RATIOS).map(item => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Select value={selectedNode.params?.resolution || '2K'} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, resolution: value } })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{RESOLUTION_OPTIONS.map(item => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{selectedNode.type === 'img2img' ? (
<div className="space-y-2">
<Label></Label>
<Select value={selectedNode.referenceImage || ''} onValueChange={(value) => updateNode(selectedNode.id, { referenceImage: value })}>
<SelectTrigger><SelectValue placeholder="选择画布图片" /></SelectTrigger>
<SelectContent>
{state.nodes.filter(node => node.type === 'image' && node.imageUrl).map(node => (
<SelectItem key={node.id} value={node.imageUrl || ''}>{node.title}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<Button className="w-full gap-2" onClick={() => void generateForNode(selectedNode)} disabled={selectedNode.status === 'generating' || modelOptions.length === 0}>
{selectedNode.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</Button>
{state.connections.some(connection => connection.sourceNodeId === selectedNode.id) ? (
<Button variant="outline" className="w-full gap-2" onClick={() => void runDownstream(selectedNode)} disabled={selectedNode.status === 'generating' || modelOptions.length === 0}>
<Wand2 className="h-4 w-4" />
</Button>
) : null}
</>
) : null}
{selectedNode.type === 'layeredImage' ? (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2">
<Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'element')}></Button>
<Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'text')}></Button>
<Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'icon')}></Button>
</div>
<div className="space-y-2">
{(selectedNode.layers || []).map((layer, index) => (
<div key={layer.id} className="rounded-lg border p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<Input
className="h-8 min-w-0"
value={layer.name}
disabled={layer.locked}
onChange={(event) => updateLayer(selectedNode.id, layer.id, { name: event.target.value })}
/>
<Badge variant="outline">{layer.type}</Badge>
</div>
<div className="mb-2 flex gap-1">
<Button variant="outline" size="sm" onClick={() => updateLayer(selectedNode.id, layer.id, { visible: !layer.visible })}>
{layer.visible ? '隐藏' : '显示'}
</Button>
<Button variant="outline" size="sm" onClick={() => updateLayer(selectedNode.id, layer.id, { locked: !layer.locked })}>
{layer.locked ? '解锁' : '锁定'}
</Button>
<Button variant="outline" size="sm" disabled={index === 0} onClick={() => moveLayer(selectedNode.id, layer.id, -1)}></Button>
<Button variant="outline" size="sm" disabled={index === (selectedNode.layers || []).length - 1} onClick={() => moveLayer(selectedNode.id, layer.id, 1)}></Button>
</div>
<div className="grid grid-cols-4 gap-2">
<div>
<Label className="text-[11px]">X</Label>
<Input className="h-8" type="number" value={layer.x} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { x: Number(event.target.value) || 0 })} />
</div>
<div>
<Label className="text-[11px]">Y</Label>
<Input className="h-8" type="number" value={layer.y} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { y: Number(event.target.value) || 0 })} />
</div>
<div>
<Label className="text-[11px]"></Label>
<Input className="h-8" type="number" value={layer.width} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { width: Math.max(1, Number(event.target.value) || 1) })} />
</div>
<div>
<Label className="text-[11px]"></Label>
<Input className="h-8" type="number" value={layer.height} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { height: Math.max(1, Number(event.target.value) || 1) })} />
</div>
</div>
<div className="mt-2 grid grid-cols-[1fr_88px] gap-2">
<div>
<Label className="text-[11px]"></Label>
<Input className="h-8" value={layer.color || LAYER_COLORS[layer.type]} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { color: event.target.value })} />
</div>
<div>
<Label className="text-[11px]"></Label>
<Input className="h-8" type="number" min="0" max="1" step="0.1" value={layer.opacity ?? 1} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { opacity: Math.min(1, Math.max(0, Number(event.target.value))) })} />
</div>
</div>
{layer.type === 'text' ? (
<div className="mt-2">
<Label className="text-[11px]"></Label>
<Textarea className="min-h-16" value={layer.text || ''} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { text: event.target.value })} />
</div>
) : null}
<Button variant="ghost" size="sm" className="mt-2 w-full text-destructive" onClick={() => removeLayer(selectedNode.id, layer.id)}>
</Button>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-2">
<Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'png')}>
<Download className="h-3 w-3" /> PNG
</Button>
<Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'json')}>
<Download className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'psd')}>
<Download className="h-3 w-3" /> PSD
</Button>
</div>
</div>
) : null}
<Button variant="destructive" className="w-full gap-2" onClick={removeSelectedNode}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</aside>
</div>
);
}