2138 lines
95 KiB
TypeScript
Executable File
2138 lines
95 KiB
TypeScript
Executable File
'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>
|
||
);
|
||
}
|