'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 = { 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 { 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 { 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 ; } export function InfiniteCanvasWorkspace() { const { user, accessToken } = useAuth(); const { imageKeys } = useCustomApiKeys(); const managedSystemApis = useManagedSystemApis(); const fileInputRef = useRef(null); const importInputRef = useRef(null); const canvasRef = useRef(null); const flowControlsRef = useRef(null); const historyRef = useRef<{ past: CanvasProjectState[]; future: CanvasProjectState[]; skip: boolean }>({ past: [], future: [], skip: false }); const [projects, setProjects] = useState([]); const [project, setProject] = useState(null); const [state, setState] = useState({ nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } }); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedNodeIds, setSelectedNodeIds] = useState([]); const [addMenu, setAddMenu] = useState(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(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) => { 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) => { 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) => { 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 = {}) => { 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 = {}) => { 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) => { 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 = { 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 = { 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(); 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) => { 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) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; setIsDraggingFile(false); }, []); const handleCanvasDrop = useCallback((event: React.DragEvent) => { 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 (

正在打开无限画布

); } if (!user) { return (

登录后使用无限画布

画布项目会保存到你的账号下,可用于多轮图像创作和后续编辑。

); } return (
{ if (!project) return; setProject({ ...project, title: event.target.value }); setDirty(true); }} disabled={!project} /> {dirty ? 未保存 : 已保存}
{Math.round(state.viewport.zoom * 100)}%
{ flowControlsRef.current = controls; }} onPaneClick={handlePaneClick} onPaneDoubleClick={handlePaneDoubleClick} /> {isDraggingFile ? (
松开鼠标,把图片添加到画布
) : null}
空白点击添加模块 · 双击添加文生图 · Ctrl+Z 撤销 · Ctrl+D 复制 · Delete 删除
{!project ? (
) : null}
{selectedNodes.length > 1 ? ( <> ) : null}
{addMenu ? (
) : null}