import { useCallback, useState, type Dispatch, type SetStateAction } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { App } from "antd"; import { saveAs } from "file-saver"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isCanvasFolderNode, isFrameNode } from "@/lib/canvas/canvas-frame"; import { buildCanvasMediaDownloadFileName } from "@/lib/canvas/canvas-media-download"; import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain"; import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas"; type UseCanvasNodeEditorOptions = { canvasId: string; canvasTitle: string; domainProjectId?: string; nodesRef: { current: CanvasNodeData[] }; setNodes: Dispatch>; setSelectedNodeIds: Dispatch>>; setSelectedConnectionId: Dispatch>; setDialogNodeId: Dispatch>; setToolbarNodeId: Dispatch>; setHoveredNodeId: Dispatch>; }; export function useCanvasNodeEditor({ canvasId, canvasTitle, domainProjectId, nodesRef, setNodes, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setToolbarNodeId, setHoveredNodeId, }: UseCanvasNodeEditorOptions) { const { message } = App.useApp(); const queryClient = useQueryClient(); const [collapsingBatchIds, setCollapsingBatchIds] = useState>(new Set()); const [openingBatchIds, setOpeningBatchIds] = useState>(new Set()); const handleNodeResize = useCallback((nodeId: string, width: number, height: number, position?: Position) => { setNodes((current) => { let changed = false; const next = current.map((node) => { if (node.id !== nodeId || node.metadata?.locked) return node; const nextPosition = position || node.position; if (node.width === width && node.height === height && node.position.x === nextPosition.x && node.position.y === nextPosition.y) return node; changed = true; // 打上「用户手动定过尺寸」标记:图片按真实比例自动适配时要避让它, // 否则每次图片重新加载都会把用户拉过的尺寸改回去。 const resized = { ...node, width, height, position: nextPosition, metadata: { ...node.metadata, manualSize: true } }; if (!isFrameNode(node) || node.metadata?.frame?.collapsed) return resized; return { ...resized, metadata: { ...resized.metadata, frame: { collapsed: false, expandedWidth: width, expandedHeight: height } } }; }); return changed ? next : current; }); }, [setNodes]); const toggleFrameCollapsed = useCallback((nodeId: string) => { const frame = nodesRef.current.find((node) => node.id === nodeId && isFrameNode(node)); if (!frame) return; const collapsed = Boolean(frame.metadata?.frame?.collapsed); const childIds = getFrameChildIds(nodeId, nodesRef.current); setNodes((current) => current.map((node) => { if (node.id !== nodeId) return node; const frameState = node.metadata?.frame; const folder = isCanvasFolderNode(node); return collapsed ? { ...node, width: frameState?.expandedWidth || NODE_DEFAULT_SIZE[CanvasNodeType.Frame].width, height: frameState?.expandedHeight || NODE_DEFAULT_SIZE[CanvasNodeType.Frame].height, metadata: { ...node.metadata, frame: { collapsed: false, expandedWidth: frameState?.expandedWidth || NODE_DEFAULT_SIZE[CanvasNodeType.Frame].width, expandedHeight: frameState?.expandedHeight || NODE_DEFAULT_SIZE[CanvasNodeType.Frame].height } } } : { ...node, width: folder ? FOLDER_COLLAPSED_WIDTH : FRAME_COLLAPSED_WIDTH, height: folder ? FOLDER_COLLAPSED_HEIGHT : FRAME_COLLAPSED_HEIGHT, metadata: { ...node.metadata, frame: { collapsed: true, expandedWidth: node.width, expandedHeight: node.height } } }; }), ); setSelectedNodeIds(new Set([nodeId])); setSelectedConnectionId(null); setDialogNodeId((current) => (current && childIds.has(current) ? null : current)); setToolbarNodeId(null); setHoveredNodeId(null); }, [nodesRef, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]); const handleNodeTitleChange = useCallback((nodeId: string, title: string) => { setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title } : node))); }, [setNodes]); const handleFolderStyleChange = useCallback((nodeId: string, style: CanvasFolderStyle) => { setNodes((current) => current.map((node) => { if (node.id !== nodeId || !isCanvasFolderNode(node)) return node; const folder = node.metadata!.folder!; return { ...node, metadata: { ...node.metadata, folder: { ...folder, style, createdAt: folder.createdAt || new Date().toISOString() } } }; })); }, [setNodes]); const handleFolderThemeChange = useCallback((nodeId: string, theme: CanvasFolderTheme) => { setNodes((current) => current.map((node) => { if (node.id !== nodeId || !isCanvasFolderNode(node)) return node; const folder = node.metadata!.folder!; return { ...node, metadata: { ...node.metadata, folder: { ...folder, theme, themeCover: undefined, createdAt: folder.createdAt || new Date().toISOString() } } }; })); }, [setNodes]); const toggleNodeFreeResize = useCallback((nodeId: string) => { setNodes((current) => current.map((node) => { if (node.id !== nodeId) return node; const freeResize = !node.metadata?.freeResize; if (freeResize || node.type !== CanvasNodeType.Image) return { ...node, metadata: { ...node.metadata, freeResize } }; const ratio = (node.metadata?.naturalWidth || node.width) / (node.metadata?.naturalHeight || node.height || 1); const height = node.width / ratio; return { ...node, height, position: { x: node.position.x, y: node.position.y + node.height / 2 - height / 2 }, metadata: { ...node.metadata, freeResize } }; }), ); }, [setNodes]); const handleNodeContentChange = useCallback((nodeId: string, content: string) => { setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content, richText: undefined } } : node))); }, [setNodes]); const toggleBatchExpanded = useCallback((nodeId: string) => { const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded); const updateMotionState = isExpanded ? setCollapsingBatchIds : setOpeningBatchIds; updateMotionState((current) => new Set(current).add(nodeId)); window.setTimeout(() => { updateMotionState((current) => { const next = new Set(current); next.delete(nodeId); return next; }); }, isExpanded ? 320 : 260); setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, imageBatchExpanded: !node.metadata?.imageBatchExpanded } } : node))); }, [nodesRef, setNodes]); const setBatchPrimary = useCallback((child: CanvasNodeData) => { const rootId = child.metadata?.batchRootId; if (!rootId || !child.metadata?.content) return; setNodes((current) => current.map((node) => node.id === rootId ? applyBatchPrimaryImage(node, child) : node, ), ); }, [setNodes]); const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => { setNodes((current) => current.map((node) => { if (node.id !== nodeId) return node; const hasExistingContent = (node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim())) || (node.type === CanvasNodeType.Image && Boolean(node.metadata?.content)); const previousPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); const metadata = moderationFailure && prompt !== previousPrompt ? resetGenerationTaskMetadata(node.metadata, node.metadata?.content ? "success" : "idle") : node.metadata; const promptTemplateMetadata = prompt !== previousPrompt && metadata?.promptTemplateOperation ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined } : {}; return { ...node, metadata: hasExistingContent ? { ...metadata, ...promptTemplateMetadata, composerContent: prompt } : { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt } }; })); }, [setNodes]); const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial) => { setNodes((current) => { const next = current.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node)); // 生成入口读取 nodesRef;同步写入,避免刚修改工作流比例就立即生成时仍提交旧值。 nodesRef.current = next; return next; }); if (!patch.assetCategory) return; const node = nodesRef.current.find((item) => item.id === nodeId); if (!node?.metadata?.content?.trim()) return; const updatedNode = applyNodeConfigPatch(node, patch); void ensureCanvasNodeAsset({ canvasId, domainProjectId, node: updatedNode, source: "canvas-manual", category: patch.assetCategory }) .then(async (result) => { setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)); if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] }); message.success("资产分类已更新"); }) .catch((error) => message.error(error instanceof Error ? error.message : "资产分类更新失败")); }, [canvasId, domainProjectId, message, nodesRef, queryClient, setNodes]); const downloadNodeImage = useCallback((node: CanvasNodeData) => { if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return; saveAs(node.metadata.content, buildCanvasMediaDownloadFileName(canvasTitle, node)); }, [canvasTitle]); const saveNodeAsset = useCallback(async (node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Text && node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) return message.error("当前节点类型不能保存为素材"); if (!node.metadata?.content?.trim()) return message.error("当前节点没有可保存的内容"); try { const result = await ensureCanvasNodeAsset({ canvasId, domainProjectId, node, source: "canvas-manual" }); setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)); if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] }); message.success(result.linkedToProject ? "已加入项目资产" : "已加入我的素材"); } catch (error) { message.error(error instanceof Error ? error.message : "素材保存失败"); } }, [canvasId, domainProjectId, message, queryClient, setNodes]); const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => { setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, fontSize } } : node))); }, [setNodes]); return { collapsingBatchIds, downloadNodeImage, handleConfigNodeChange, handleFolderStyleChange, handleFolderThemeChange, handleFontSizeChange, handleNodeContentChange, handleNodePromptChange, handleNodeResize, handleNodeTitleChange, openingBatchIds, saveNodeAsset, setBatchPrimary, toggleBatchExpanded, toggleFrameCollapsed, toggleNodeFreeResize, }; }