feat(assets): 素材库与画布 - 新增可交互通用文件夹

This commit is contained in:
echoD886
2026-08-20 15:16:45 +08:00
parent 9ee71d4650
commit 935108edca
35 changed files with 2010 additions and 155 deletions

View File

@@ -14,6 +14,7 @@ type CanvasProjectContextMenuProps = {
screenToCanvas: (clientX: number, clientY: number) => Position;
onClose: () => void;
onAddNode: (type: CanvasNodeType, position: Position) => void;
onAddFolder: (position: Position) => void;
onChooseStyle: () => void;
onOpenDirector: (position?: Position) => void;
onUpload: (nodeId: string | undefined, position: Position) => void;
@@ -53,6 +54,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onAddNode={(type) => {
if (menu.type === "canvas") props.onAddNode(type, menu.position);
}}
onAddFolder={() => {
if (menu.type === "canvas") props.onAddFolder(menu.position);
}}
onChooseStyle={props.onChooseStyle}
onOpenDirector={props.onOpenDirector}
onUpload={() => props.onUpload(menu.type === "node" ? menu.nodeId : undefined, menuPosition())}

View File

@@ -7,7 +7,7 @@ import { CanvasNode } from "@/components/canvas/canvas-node";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
import type { CanvasDisplayConnection, CanvasFolderStyle, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null;
@@ -26,6 +26,7 @@ type CanvasProjectWorldLayersProps = {
nodeById: Map<string, CanvasNodeData>;
visibleNodes: CanvasNodeData[];
frameChildrenById: Map<string, CanvasNodeData[]>;
linkedFolderPreviewNodesById: Map<string, CanvasNodeData[]>;
dragPreview: DragPreview;
selectedNodeIds: Set<string>;
frameDropTargetId: string | null;
@@ -55,6 +56,7 @@ type CanvasProjectWorldLayersProps = {
onConnectStart: (event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => void;
onNodeResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onToggleFrame: (nodeId: string) => void;
onFolderStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
onNodeTitleChange: (nodeId: string, title: string) => void;
onNodeContextMenu: (event: ReactMouseEvent, nodeId: string) => void;
onNodeContentChange: (nodeId: string, content: string) => void;
@@ -77,6 +79,13 @@ const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
const { viewportScale } = props;
const framePreviewNodes = (node: CanvasNodeData) => {
const assetFolderId = node.metadata?.folder?.assetFolderId;
if (assetFolderId) return props.linkedFolderPreviewNodesById.get(assetFolderId) || EMPTY_CANVAS_NODES;
const localChildren = props.frameChildrenById.get(node.id) || EMPTY_CANVAS_NODES;
if (localChildren.length) return localChildren;
return EMPTY_CANVAS_NODES;
};
return (
<>
<svg
@@ -106,13 +115,14 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
key={node.id}
data={node}
dragOffset={props.dragPreview?.nodeIds.has(node.id) ? props.dragPreview : undefined}
childNodes={props.frameChildrenById.get(node.id) || EMPTY_CANVAS_NODES}
childNodes={framePreviewNodes(node)}
scale={viewportScale}
isSelected={props.selectedNodeIds.has(node.id)}
isDropTarget={props.frameDropTargetId === node.id}
onMouseDown={props.onNodeMouseDown}
onResize={props.onNodeResize}
onToggleCollapsed={props.onToggleFrame}
onFolderStyleChange={props.onFolderStyleChange}
onTitleChange={props.onNodeTitleChange}
onContextMenu={props.onNodeContextMenu}
/>

View File

@@ -17,6 +17,7 @@ import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-charac
import { shouldAutoConnectCanvasRuntime } from "@/lib/canvas/local-runtime-connection";
import { useAssetStore } from "@/stores/use-asset-store";
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { App } from "antd";
@@ -108,6 +109,7 @@ import {
CanvasNodeType,
type CanvasAssistantSession,
type CanvasConnection,
type CanvasFolderStyle,
type CanvasNodeData,
type CanvasMediaPerformanceMode,
type StoryboardColumn,
@@ -200,6 +202,7 @@ function InfiniteCanvasPage() {
const [stylePickerOpen, setStylePickerOpen] = useState(false);
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all");
const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState<Position | undefined>();
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [subtitleNodeId, setSubtitleNodeId] = useState<string | null>(null);
@@ -334,6 +337,23 @@ function InfiniteCanvasPage() {
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
const refetchLinkedProject = linkedProjectQuery.refetch;
const archiveNodesToLinkedFolder = useCallback((folder: CanvasNodeData, droppedNodes: CanvasNodeData[]) => {
const folderId = folder.metadata?.folder?.assetFolderId;
const domainProjectId = folder.metadata?.folder?.projectId || linkedProjectId;
if (!folderId || !domainProjectId || !droppedNodes.length) return;
void Promise.all(droppedNodes.map((node) => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, folderId, node, source: "canvas-manual" })))
.then((results) => {
const archivedByNodeId = new Map(droppedNodes.map((node, index) => [node.id, { assetId: results[index].assetId, content: node.metadata?.content, previousAssetId: node.metadata?.assetId }]));
setNodes((current) => current.map((node) => {
const archived = archivedByNodeId.get(node.id);
if (!archived || node.metadata?.content !== archived.content || node.metadata?.assetId !== archived.previousAssetId) return node;
return { ...node, metadata: { ...node.metadata, assetId: archived.assetId } };
}));
void refetchLinkedProject();
message.success(`已归档到“${folder.title}”`);
})
.catch((error) => message.error(error instanceof Error ? error.message : "素材归档失败"));
}, [linkedProjectId, message, projectId, refetchLinkedProject, setNodes]);
useEffect(() => {
if (!projectLoaded || !linkedProjectQuery.data) return;
setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets));
@@ -621,9 +641,10 @@ function InfiniteCanvasPage() {
);
const openProjectAssets = useCallback(
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas") => {
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas", initialFolderId = "all") => {
setProjectAssetScope(scope);
setProjectAssetInitialCategory(initialCategory);
setProjectAssetInitialFolderId(initialFolderId);
setProjectAssetInsertPosition(position);
setProjectAssetOpen(true);
// 资产与项目实时同步:打开弹窗前刷新关联短剧项目资产,避免缓存导致资产列表空白/过期。
@@ -647,6 +668,7 @@ function InfiniteCanvasPage() {
const closeProjectAssets = useCallback(() => {
setProjectAssetOpen(false);
setProjectAssetInsertPosition(undefined);
setProjectAssetInitialFolderId("all");
}, []);
const {
@@ -749,6 +771,7 @@ function InfiniteCanvasPage() {
arrangeSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,
createNode,
createReferenceGroup,
createStoryboardGroup,
@@ -863,6 +886,7 @@ function InfiniteCanvasPage() {
onNodeInteractionStart: handleNodeInteractionStart,
onNodeClick: handleSelectedNodeClick,
onBatchConnectionTarget: handleBatchConnectionTargetClick,
onLinkedFolderDrop: archiveNodesToLinkedFolder,
onDeselect: handleCanvasDeselect,
onSelectionBoxEnd: () => setCanvasTool((tool) => (tool === "box-select" ? "move" : tool)),
});
@@ -891,6 +915,7 @@ function InfiniteCanvasPage() {
collapsingBatchIds,
downloadNodeImage,
handleConfigNodeChange,
handleFolderStyleChange,
handleFontSizeChange,
handleNodeContentChange,
handleNodePromptChange,
@@ -914,6 +939,60 @@ function InfiniteCanvasPage() {
setHoveredNodeId,
});
const handleProjectFolderInsert = useCallback((folderId: string) => {
const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);
if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试");
const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, createdAt: folder.createdAt });
}, [createFolder, linkedProjectId, linkedProjectQuery.data?.assetFolders, projectAssetInsertPosition]);
const handleFrameToggle = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
const linkedFolderId = node?.metadata?.folder?.assetFolderId;
if (linkedFolderId) {
openProjectAssets("all", node ? { x: node.position.x + node.width + 40, y: node.position.y } : undefined, "canvas", linkedFolderId);
return;
}
toggleFrameCollapsed(nodeId);
}, [nodesRef, openProjectAssets, toggleFrameCollapsed]);
const linkedFolderPreviewNodesById = useMemo(() => {
const result = new Map<string, CanvasNodeData[]>();
const localById = new Map(assets.map((asset) => [asset.id, asset]));
for (const asset of linkedProjectQuery.data?.assets || []) {
if (!asset.folderId) continue;
const local = localById.get(asset.id);
const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
const remoteResourceId = resourceIdFromStorageKey(asset.storageKey);
const content = characterCover ? resourceFileUrl(characterCover.resourceId) : local?.kind === "image" ? local.data.dataUrl || local.coverUrl : local?.kind === "video" || local?.kind === "audio" ? local.data.url : local?.kind === "text" ? local.data.content : remoteResourceId ? resourceFileUrl(remoteResourceId) : asset.previewText || "";
const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } };
const current = result.get(asset.folderId) || [];
current.push(preview);
result.set(asset.folderId, current);
}
return result;
}, [assets, linkedProjectQuery.data?.assets]);
useEffect(() => {
const folders = linkedProjectQuery.data?.assetFolders;
if (!folders?.length) return;
const byId = new Map(folders.map((folder) => [folder.id, folder]));
setNodes((current) => {
let changed = false;
const next = current.map((node) => {
const folderId = node.metadata?.folder?.assetFolderId;
const folder = folderId ? byId.get(folderId) : undefined;
if (!folder) return node;
const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
if (node.title === folder.name && node.metadata?.folder?.style === style) return node;
changed = true;
return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style } } };
});
return changed ? next : current;
});
}, [linkedProjectQuery.data?.assetFolders, setNodes]);
const {
activeDirectorScene,
activeNodeId,
@@ -1741,6 +1820,7 @@ function InfiniteCanvasPage() {
nodeById={nodeById}
visibleNodes={visibleNodes}
frameChildrenById={frameChildrenById}
linkedFolderPreviewNodesById={linkedFolderPreviewNodesById}
dragPreview={dragPreview}
selectedNodeIds={selectedNodeIds}
frameDropTargetId={frameDropTargetId}
@@ -1778,7 +1858,8 @@ function InfiniteCanvasPage() {
onNodeHoverEnd={handleCanvasNodeHoverEnd}
onConnectStart={handleConnectStart}
onNodeResize={handleNodeResize}
onToggleFrame={toggleFrameCollapsed}
onToggleFrame={handleFrameToggle}
onFolderStyleChange={handleFolderStyleChange}
onNodeTitleChange={handleNodeTitleChange}
onNodeContextMenu={handleNodeContextMenu}
onNodeContentChange={handleNodeContentChange}
@@ -1835,6 +1916,7 @@ function InfiniteCanvasPage() {
onChooseStyle={() => setStylePickerOpen(true)}
onAddScript={() => createNode(CanvasNodeType.Script)}
onAddFrame={() => createNode(CanvasNodeType.Frame)}
onAddFolder={createFolder}
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
onOpenDirector={() => createDirectorShot()}
onUndo={undoCanvas}
@@ -2045,6 +2127,7 @@ function InfiniteCanvasPage() {
screenToCanvas={screenToCanvas}
onClose={() => setContextMenu(null)}
onAddNode={(type, position) => createNode(type, position)}
onAddFolder={createFolder}
onChooseStyle={() => setStylePickerOpen(true)}
onOpenDirector={createDirectorShot}
onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
@@ -2071,7 +2154,7 @@ function InfiniteCanvasPage() {
void copyNodeMediaUrlToClipboard(node);
}}
onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
onToggleFrame={(node) => toggleFrameCollapsed(node.id)}
onToggleFrame={(node) => handleFrameToggle(node.id)}
/>
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />
@@ -2276,7 +2359,7 @@ function InfiniteCanvasPage() {
/>
<AssetPickerModal open={assetPickerOpen} onInsert={handleTimelineAssetInsert} onClose={closeAssetPicker} />
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} />
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} initialFolderId={projectAssetInitialFolderId} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined} />
{codexCompactAgent && !assistantMounted ? (
<CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} />
) : null}

View File

@@ -14,7 +14,7 @@ import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader"
import { WorkspaceState } from "@/components/layout/workspace-state";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canvasThemes } from "@/lib/canvas-theme";
import { isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import { getPublicCanvasShare } from "@/services/api/canvas-share";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -209,8 +209,8 @@ export default function SharedCanvasPage() {
const frame = node.metadata?.frame;
return {
...node,
width: collapsed ? 240 : frame?.expandedWidth || node.width,
height: collapsed ? 144 : frame?.expandedHeight || node.height,
width: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_WIDTH : 240) : frame?.expandedWidth || node.width,
height: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_HEIGHT : 144) : frame?.expandedHeight || node.height,
metadata: { ...node.metadata, frame: { collapsed, expandedWidth: collapsed ? node.width : frame?.expandedWidth || node.width, expandedHeight: collapsed ? node.height : frame?.expandedHeight || node.height } },
};
}));
@@ -244,7 +244,7 @@ export default function SharedCanvasPage() {
const dragged = [node, ...(frameChildrenById.get(nodeId) || [])];
dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false };
document.body.style.cursor = "grabbing";
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} isConnecting={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onFolderStyleChange={() => undefined} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} isConnecting={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
event.stopPropagation();
if (event.button !== 0) return;
const target = nodes.find((item) => item.id === nodeId);

View File

@@ -4,12 +4,12 @@ import { App } from "antd";
import { saveAs } from "file-saver";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isFrameNode } from "@/lib/canvas/canvas-frame";
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 CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
type UseCanvasNodeEditorOptions = {
canvasId: string;
@@ -64,9 +64,10 @@ export function useCanvasNodeEditor({
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: FRAME_COLLAPSED_WIDTH, height: FRAME_COLLAPSED_HEIGHT, metadata: { ...node.metadata, frame: { collapsed: true, expandedWidth: node.width, expandedHeight: node.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]));
@@ -80,6 +81,14 @@ export function useCanvasNodeEditor({
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 toggleNodeFreeResize = useCallback((nodeId: string) => {
setNodes((current) =>
current.map((node) => {
@@ -180,6 +189,7 @@ export function useCanvasNodeEditor({
collapsingBatchIds,
downloadNodeImage,
handleConfigNodeChange,
handleFolderStyleChange,
handleFontSizeChange,
handleNodeContentChange,
handleNodePromptChange,

View File

@@ -3,11 +3,12 @@ import { App } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { createCanvasNode, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { useUserStore } from "@/stores/use-user-store";
@@ -132,6 +133,28 @@ export function useCanvasNodeOperations({
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId, tldrawLicenseKey]);
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; createdAt: string }) => {
const folder = createCanvasNode(CanvasNodeType.Frame, position || getCanvasCenter(), {
frame: {
collapsed: true,
expandedWidth: NODE_DEFAULT_SIZE[CanvasNodeType.Frame].width,
expandedHeight: NODE_DEFAULT_SIZE[CanvasNodeType.Frame].height,
},
folder: {
style: linked?.style || "glass",
createdAt: linked?.createdAt || new Date().toISOString(),
assetFolderId: linked?.id,
projectId: linked?.projectId,
},
});
folder.title = linked?.title || "我的文件";
folder.width = FOLDER_COLLAPSED_WIDTH;
folder.height = FOLDER_COLLAPSED_HEIGHT;
commitNodes([...nodesRef.current, folder]);
selectNodes(new Set([folder.id]));
message.success(linked ? "素材文件夹已放到画布,打开可浏览其中内容" : "文件夹已创建,可拖入任意非容器节点");
}, [commitNodes, getCanvasCenter, message, nodesRef, selectNodes]);
const arrangeSelectedNodes = useCallback((mode: "row" | "column" | "grid" | "flow") => {
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));
if (selected.length < 2) return;
@@ -464,6 +487,7 @@ export function useCanvasNodeOperations({
arrangeSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,
createNode,
createReferenceGroup,
createStoryboardGroup,

View File

@@ -20,6 +20,7 @@ type UseCanvasSelectionControllerOptions = {
onNodeInteractionStart: (selectionModifier: boolean) => void;
onNodeClick: (node: CanvasNodeData) => void;
onBatchConnectionTarget?: (event: ReactMouseEvent, nodeId: string) => boolean;
onLinkedFolderDrop?: (folder: CanvasNodeData, nodes: CanvasNodeData[]) => void;
onDeselect: () => void;
onSelectionBoxEnd?: () => void;
};
@@ -59,6 +60,7 @@ export function useCanvasSelectionController({
onNodeInteractionStart,
onNodeClick,
onBatchConnectionTarget,
onLinkedFolderDrop,
onDeselect,
onSelectionBoxEnd,
}: UseCanvasSelectionControllerOptions) {
@@ -205,13 +207,16 @@ export function useCanvasSelectionController({
setAlignmentGuides({});
if (dragRef.current.hasMoved) {
const draggedNodeIds = new Set(dragRef.current.draggedNodeIds);
setNodes((currentNodes) => {
const positioned = clientX == null || clientY == null ? currentNodes : currentNodes.map((node) => {
const initial = initialById.get(node.id);
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
});
return applyFrameDrop(positioned, draggedNodeIds, findFrameDropTarget(positioned, draggedNodeIds));
const positioned = clientX == null || clientY == null ? nodesRef.current : nodesRef.current.map((node) => {
const initial = initialById.get(node.id);
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
});
const targetId = findFrameDropTarget(positioned, draggedNodeIds);
const target = targetId ? positioned.find((node) => node.id === targetId) : undefined;
const linkedFolder = target?.metadata?.folder?.assetFolderId ? target : undefined;
// 素材库文件夹只建立归档关系,不把画布节点变成其本地子节点。
setNodes(linkedFolder ? positioned : applyFrameDrop(positioned, draggedNodeIds, targetId));
if (linkedFolder) onLinkedFolderDrop?.(linkedFolder, positioned.filter((node) => draggedNodeIds.has(node.id)));
}
setFrameDropTargetId(null);
alignmentContextRef.current = null;
@@ -220,7 +225,7 @@ export function useCanvasSelectionController({
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
if (clickedNode) onNodeClick(clickedNode);
}
}, [historyPausedRef, nodesRef, onNodeClick, setNodes, viewportRef]);
}, [historyPausedRef, nodesRef, onLinkedFolderDrop, onNodeClick, setNodes, viewportRef]);
const handleMouseMove = useCallback((event: MouseEvent) => {
if (!dragRef.current.isDraggingNode) return;