上传接口返回的 width/height 是可选字段(services/api/resources.ts),拿不到时节点落到 默认的横向比例,竖图被放进一个宽盒子、两侧留大片空白。改为在图片解码后于客户端量真实 尺寸并按比例校正节点 —— 这样不依赖服务端是否返回尺寸。 判据是「用户有没有手动定过尺寸」,**不是**「有没有量过尺寸」。第一版按后者写,结果所有 已经存过 naturalWidth 的既有节点永远得不到修正,改完等于没改。现在由 handleNodeResize 在手动拉伸时打 metadata.manualSize 标记,自动适配只避让它和 freeResize(自由比例是用户 显式要求脱离原始比例,自动校正会跟他打架)。 顺带收益:naturalWidth / naturalHeight 补齐后,canvas-node.tsx 的锁比例拉伸也会按真实 比例走 —— 它本来就在读这两个字段,之前多数节点拿不到。 已知影响:既有节点因为没有 manualSize 标记,首次重新加载时会自动校正一次尺寸。 对被 letterbox 的图是修复,但确实会让画布上现有图片节点的尺寸变动一次。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
13 KiB
TypeScript
217 lines
13 KiB
TypeScript
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 { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain";
|
|
import { audioExtension, imageExtension, 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;
|
|
domainProjectId?: string;
|
|
nodesRef: { current: CanvasNodeData[] };
|
|
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
|
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
|
|
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
|
|
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
|
|
setToolbarNodeId: Dispatch<SetStateAction<string | null>>;
|
|
setHoveredNodeId: Dispatch<SetStateAction<string | null>>;
|
|
};
|
|
|
|
export function useCanvasNodeEditor({
|
|
canvasId,
|
|
domainProjectId,
|
|
nodesRef,
|
|
setNodes,
|
|
setSelectedNodeIds,
|
|
setSelectedConnectionId,
|
|
setDialogNodeId,
|
|
setToolbarNodeId,
|
|
setHoveredNodeId,
|
|
}: UseCanvasNodeEditorOptions) {
|
|
const { message } = App.useApp();
|
|
const queryClient = useQueryClient();
|
|
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
|
|
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(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<CanvasNodeMetadata>) => {
|
|
setNodes((current) => current.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node)));
|
|
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, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`);
|
|
}, []);
|
|
|
|
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,
|
|
};
|
|
}
|