feat(platform): 开源发布 - 初始化 Open AI Canvas

This commit is contained in:
ddCat
2026-07-22 14:18:14 +08:00
commit aea35896eb
365 changed files with 66410 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { saveAs } from "file-saver";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { getDataUrlByteSize } from "@/lib/image-utils";
import { FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isFrameNode } from "@/lib/canvas/canvas-frame";
import { 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 { useAssetStore } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
type UseCanvasNodeEditorOptions = {
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
setDocumentEditorNodeId: Dispatch<SetStateAction<string | null>>;
setEditingNodeId: Dispatch<SetStateAction<string | null>>;
setEditRequestNonce: Dispatch<SetStateAction<number>>;
setToolbarNodeId: Dispatch<SetStateAction<string | null>>;
setHoveredNodeId: Dispatch<SetStateAction<string | null>>;
};
export function useCanvasNodeEditor({
nodesRef,
setNodes,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
setDocumentEditorNodeId,
setEditingNodeId,
setEditRequestNonce,
setToolbarNodeId,
setHoveredNodeId,
}: UseCanvasNodeEditorOptions) {
const { message } = App.useApp();
const addAsset = useAssetStore((state) => state.addAsset);
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 };
if (!isFrameNode(node) || node.metadata?.frame?.collapsed) return resized;
return { ...resized, metadata: { ...node.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;
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: FRAME_COLLAPSED_WIDTH, 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 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 } } : 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
? { ...node, width: child.width, height: child.height, metadata: { ...node.metadata, content: child.metadata?.content, primaryImageId: child.id, naturalWidth: child.metadata?.naturalWidth, naturalHeight: child.metadata?.naturalHeight, freeResize: child.metadata?.freeResize } }
: node,
),
);
}, [setNodes]);
const openTextEditor = useCallback((node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text) return;
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
if (node.metadata?.document) {
setDocumentEditorNodeId(node.id);
setDialogNodeId(null);
return;
}
setDialogNodeId(node.id);
setEditingNodeId(node.id);
setEditRequestNonce((value) => value + 1);
}, [setDialogNodeId, setDocumentEditorNodeId, setEditRequestNonce, setEditingNodeId, setSelectedConnectionId, setSelectedNodeIds]);
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;
return { ...node, metadata: hasExistingContent ? { ...metadata, composerContent: prompt } : { ...metadata, prompt, composerContent: prompt } };
}));
}, [setNodes]);
const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => {
setNodes((current) => current.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node)));
}, [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) {
const content = node.metadata?.content?.trim();
if (!content) return message.error("没有可保存的文本");
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
message.success("已加入我的素材");
return;
}
if (node.type === CanvasNodeType.Video) {
if (!node.metadata?.content) return message.error("没有可保存的视频");
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
message.success("已加入我的素材");
return;
}
if (!node.metadata?.content) return message.error("没有可保存的图片");
const dataUrl = node.metadata.storageKey ? "" : node.metadata.content;
addAsset({ kind: "image", title: node.metadata?.prompt?.slice(0, 24) || "画布图片", coverUrl: node.metadata.content, tags: [], source: "Canvas", data: { dataUrl, storageKey: node.metadata.storageKey, width: node.metadata.naturalWidth || node.width, height: node.metadata.naturalHeight || node.height, bytes: node.metadata.bytes || getDataUrlByteSize(dataUrl), mimeType: node.metadata.mimeType || "image/png" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
message.success("已加入我的素材");
}, [addAsset, message]);
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,
handleFontSizeChange,
handleNodeContentChange,
handleNodePromptChange,
handleNodeResize,
handleNodeTitleChange,
openTextEditor,
openingBatchIds,
saveNodeAsset,
setBatchPrimary,
toggleBatchExpanded,
toggleFrameCollapsed,
toggleNodeFreeResize,
};
}