feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验

统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
ddCat
2026-09-01 14:57:05 +08:00
parent 434f63e153
commit 3f4a9ea3d0
45 changed files with 1348 additions and 286 deletions

View File

@@ -50,9 +50,8 @@ export async function executeImageGeneration({
const count = getGenerationCount(generationConfig.count);
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const isCopiedVariant = count === 1 && isImageNode && Boolean(sourceNode?.metadata?.copiedFromNodeId || sourceNode?.metadata?.versionOfNodeId || sourceNode?.title.endsWith(" Copy") || sourceNode?.title.includes(" · "));
const reuseSourceNode = canGenerateImageInPlace(sourceNode) || isCopiedVariant;
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && (Boolean(sourceNode?.metadata?.copiedFromNodeId) || sourceNode?.title.endsWith(" Copy"));
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && reuseSourceNode;
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
const referenceImages = generationContext.referenceImages;
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
@@ -289,4 +288,3 @@ export async function executeImageGeneration({
return next;
});
}

View File

@@ -1,6 +1,7 @@
import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canGenerateMediaInPlace } from "@/lib/canvas/canvas-generation-layout";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { nextCanvasVersionLabel } from "@/lib/canvas/canvas-layout";
import { buildAudioGenerationMetadata, buildVideoGenerationMetadata, generationReferenceUrls, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
@@ -33,9 +34,9 @@ export async function executeVideoGeneration({
retryContext,
}: CanvasGenerationExecution) {
const spec = nodeSizeFromRatio(generationConfig.size, NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, NODE_DEFAULT_SIZE[CanvasNodeType.Video].height) || NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content;
const isExistingVideoNode = sourceNode?.type === CanvasNodeType.Video && Boolean(sourceNode.metadata?.content);
const videoId = isEmptyVideoNode ? nodeId : nanoid();
const reuseSourceNode = canGenerateMediaInPlace(sourceNode, CanvasNodeType.Video);
const isExistingVideoNode = sourceNode?.type === CanvasNodeType.Video && Boolean(sourceNode.metadata?.content) && !reuseSourceNode;
const videoId = reuseSourceNode ? nodeId : nanoid();
const versionRootId = isExistingVideoNode && sourceNode ? sourceNode.metadata?.versionOfNodeId || sourceNode.id : undefined;
const parent = sourceNode?.position || { x: 0, y: 0 };
const videoGenerationMetadata = buildVideoGenerationMetadata(sourceNode, generationContext, generationConfig);
@@ -43,11 +44,11 @@ export async function executeVideoGeneration({
id: videoId,
type: CanvasNodeType.Video,
title: effectivePrompt.slice(0, 32) || "Generated Video",
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
width: isEmptyVideoNode ? sourceNode.width : spec.width,
height: isEmptyVideoNode ? sourceNode.height : spec.height,
position: reuseSourceNode ? sourceNode!.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
width: reuseSourceNode ? sourceNode!.width : spec.width,
height: reuseSourceNode ? sourceNode!.height : spec.height,
metadata: {
...(isEmptyVideoNode ? sourceNode.metadata || {} : {}),
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
prompt: effectivePrompt,
status: NODE_STATUS_LOADING,
errorDetails: undefined,
@@ -69,7 +70,7 @@ export async function executeVideoGeneration({
registerPendingNodeIds([videoId]);
// 待生成版本先加入版本族,但只有成功结果才能替换当前主版本。
setNodes((current) => {
if (isEmptyVideoNode) return current.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node));
if (reuseSourceNode) return current.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node));
if (!isExistingVideoNode || !sourceNode) return [...current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode];
const rootId = versionRootId!;
const nextLabel = nextCanvasVersionLabel(rootId, current);
@@ -83,7 +84,7 @@ export async function executeVideoGeneration({
];
});
// 重新生成已有视频时,新节点继承源视频的上游连接,与源视频保持并行关系,而不是作为其下游子节点。
if (!isEmptyVideoNode) {
if (!reuseSourceNode) {
setConnections((current) => {
if (!isExistingVideoNode) return [...current, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }];
return [...current, ...canvasConnections.filter((connection) => connection.toNodeId === nodeId).map((connection) => ({ ...connection, id: nanoid(), toNodeId: videoId }))];

View File

@@ -24,6 +24,7 @@ type CanvasProjectContextMenuProps = {
onRedo: () => void;
onPaste: (position: Position) => void;
onCopyNode: (nodeId: string) => void;
onCreateGenerationCopy: (nodeId: string) => void;
onDuplicate: (nodeId: string) => void;
onDeleteNode: (nodeId: string) => void;
onDeleteConnection: (connectionId: string) => void;
@@ -69,6 +70,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onCopyNode={() => {
if (menu.type === "node") props.onCopyNode(menu.nodeId);
}}
onCreateGenerationCopy={() => {
if (menu.type === "node") props.onCreateGenerationCopy(menu.nodeId);
}}
onDuplicate={() => {
if (menu.type === "node") props.onDuplicate(menu.nodeId);
}}

View File

@@ -1,4 +1,4 @@
import { memo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { memo, useMemo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { Link2 } from "lucide-react";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
@@ -84,10 +84,10 @@ const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
const { viewportScale } = props;
const activeMediaNodeId = resolveActiveCanvasMediaNodeId(props.selectedNodeIds, props.nodeById);
const orderedVisibleNodes = [
const orderedVisibleNodes = useMemo(() => [
...props.visibleNodes.filter(isFrameNode),
...sortCanvasNodesByStackOrder(props.visibleNodes.filter((node) => !isFrameNode(node)), props.nodeStackOrder),
];
], [props.nodeStackOrder, props.visibleNodes]);
const framePreviewNodes = (node: CanvasNodeData) => {
const assetFolderId = node.metadata?.folder?.assetFolderId;
if (assetFolderId) return props.linkedFolderPreviewNodesById.get(assetFolderId) || EMPTY_CANVAS_NODES;
@@ -144,7 +144,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
scale={viewportScale}
isSelected={props.selectedNodeIds.has(node.id)}
mediaActive={activeMediaNodeId === node.id}
hydrateMediaPreview={activeMediaNodeId === null}
hydrateMediaPreview={activeMediaNodeId === null && !props.reduceMediaEffects}
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Modal, Select } from "antd";
@@ -19,6 +19,7 @@ import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { saveCanvasDrawing, type CanvasDrawingRenderDraft } from "@/lib/canvas/canvas-drawing-storage";
import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { listProjects } from "@/services/api/projects";
import { loadCanvasProjectPage } from "@/lib/workspace-route-modules";
export default function CanvasPage() {
const { message } = App.useApp();
@@ -31,6 +32,8 @@ export default function CanvasPage() {
const [projectFilter, setProjectFilter] = useState("all");
const loadMoreRef = useRef<HTMLDivElement>(null);
const [loadedProjectCount, setLoadedProjectCount] = useState(50);
const [openingProjectId, setOpeningProjectId] = useState("");
const openingProjectIdRef = useRef("");
const hydrated = useCanvasStore((state) => state.hydrated);
const projects = useCanvasStore((state) => state.projects);
const importProject = useCanvasStore((state) => state.importProject);
@@ -45,9 +48,16 @@ export default function CanvasPage() {
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
const handoffMode = mode === "handoff";
const forwardedQuery = agentMode || handoffMode ? `?${searchParams.toString()}` : "";
const enterProject = (id: string) => {
navigate(`/canvas/${id}${forwardedQuery}`);
};
const preloadProject = useCallback(() => {
void loadCanvasProjectPage();
}, []);
const enterProject = useCallback((id: string) => {
if (openingProjectIdRef.current) return;
openingProjectIdRef.current = id;
setOpeningProjectId(id);
preloadProject();
window.requestAnimationFrame(() => navigate(`/canvas/${id}${forwardedQuery}`));
}, [forwardedQuery, navigate, preloadProject]);
const createAndEnter = () => {
void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
@@ -342,6 +352,8 @@ export default function CanvasPage() {
project={project}
projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined}
onClick={() => enterProject(project.id)}
onPrefetch={preloadProject}
opening={openingProjectId === project.id}
/>
))}
</CollectionGrid>

View File

@@ -76,7 +76,7 @@ import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDra
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
@@ -92,7 +92,7 @@ import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
import { CanvasNodeActionContext, type CanvasNodeActionContextValue } from "@/components/canvas/canvas-node-action-context";
import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import { PortraitClearanceModal } from "@/components/canvas/portrait-clearance/portrait-clearance-modal";
import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
@@ -131,6 +131,7 @@ import {
type CanvasFolderStyle,
type CanvasFolderTheme,
type CanvasNodeData,
type CanvasNodeMetadata,
type CanvasMediaPerformanceMode,
type StoryboardColumn,
type StoryboardShotCount,
@@ -1386,6 +1387,45 @@ function InfiniteCanvasPage() {
setToolbarNodeId(null);
setPortraitClearanceNodeId(node.id);
}, []);
const duplicateNodeFromContent = useCallback((node: CanvasNodeData) => duplicateNode(node.id), [duplicateNode]);
const deleteNodeFromContent = useCallback((node: CanvasNodeData) => deleteNodes(new Set([node.id])), [deleteNodes]);
const updateNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
setNodesState((current) => {
const next = updateCanvasNode(current, nodeId, update);
nodesRef.current = next;
return next;
});
}, []);
const pendingMediaUpdatesRef = useRef(new Map<string, (node: CanvasNodeData) => CanvasNodeData>());
const mediaUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateMediaNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
const previous = pendingMediaUpdatesRef.current.get(nodeId);
pendingMediaUpdatesRef.current.set(nodeId, previous ? (node) => update(previous(node)) : update);
if (mediaUpdateTimerRef.current) return;
mediaUpdateTimerRef.current = setTimeout(() => {
const updates = pendingMediaUpdatesRef.current;
pendingMediaUpdatesRef.current = new Map();
mediaUpdateTimerRef.current = null;
if (!updates.size) return;
setNodesState((current) => {
const next = updateCanvasNodes(current, updates);
nodesRef.current = next;
return next;
});
}, 120);
}, []);
const updateNodeMetadataFromContent = useCallback((nodeId: string, patch: CanvasNodeMetadata) => {
updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
}, [updateNodeFromContent]);
const canvasNodeActions = useMemo<CanvasNodeActionContextValue>(() => ({
download: downloadNodeImage,
duplicate: duplicateNodeFromContent,
deleteNode: deleteNodeFromContent,
updateMetadata: updateNodeMetadataFromContent,
updateNode: updateNodeFromContent,
updateMediaNode: updateMediaNodeFromContent,
openPortraitClearance,
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
@@ -2202,7 +2242,7 @@ function InfiniteCanvasPage() {
onFileDragLeave={handleFileDragLeave}
onFileDragOver={handleFileDragOver}
>
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])), updateMetadata: (nodeId, patch) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))), resizeNode: (nodeId, size) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, width: size.width, height: size.height } : node))), openPortraitClearance }}>
<CanvasNodeActionContext.Provider value={canvasNodeActions}>
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
<CanvasProjectWorldLayers
projectId={projectId}
@@ -2562,6 +2602,7 @@ function InfiniteCanvasPage() {
onRedo={redoCanvas}
onPaste={pasteAtPosition}
onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))}
onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")}
onDuplicate={duplicateNode}
onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))}
onDeleteConnection={deleteConnection}

View File

@@ -340,13 +340,14 @@ export function useCanvasNodeOperations({
setContextMenu((current) => current?.type === "connection" && current.connectionId === connectionId ? null : current);
}, [commitConnections, connectionsRef, setContextMenu, setSelectedConnectionId]);
const duplicateNode = useCallback((nodeId: string) => {
const duplicateNode = useCallback((nodeId: string, duplicateMode: "variant" | "copy" = "variant") => {
const source = nodesRef.current.find((node) => node.id === nodeId);
if (!source) return;
const sources = isFrameNode(source) ? [source, ...getFrameChildren(source.id, nodesRef.current)] : [source];
const idMap = new Map(sources.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`]));
const versionRootId = isFrameNode(source) ? undefined : source.metadata?.versionOfNodeId || source.id;
const versionRootId = duplicateMode === "variant" && !isFrameNode(source) ? source.metadata?.versionOfNodeId || source.id : undefined;
const versionLabel = versionRootId ? nextCanvasVersionLabel(versionRootId, nodesRef.current) : undefined;
const copyTitle = duplicateMode === "copy" ? nextCopiedNodeTitle(source.title, nodesRef.current.map((node) => node.title)) : undefined;
const copiedNodes = sources.map((node) => {
const metadata = isolateCopiedNodeMetadata(node, idMap);
if (node.type === CanvasNodeType.Drawing) {
@@ -364,7 +365,7 @@ export function useCanvasNodeOperations({
return {
...node,
id: idMap.get(node.id)!,
title: node.id === source.id ? `${node.title.replace(/ · [A-Z]$/, "")} · ${versionLabel || "副本"}` : node.title,
title: node.id === source.id ? copyTitle || `${node.title.replace(/ · [A-Z]$/, "")} · ${versionLabel || "副本"}` : node.title,
position: { x: node.position.x + 36, y: node.position.y + 36 },
parentId: node.parentId ? idMap.get(node.parentId) || node.parentId : undefined,
metadata,
@@ -379,7 +380,7 @@ export function useCanvasNodeOperations({
}
const id = idMap.get(source.id)!;
const nextNodes = [
...nodesRef.current.map((node) => node.id === source.id && versionRootId && !node.metadata?.versionLabel ? { ...node, title: `${node.title} · A`, metadata: { ...node.metadata, versionOfNodeId: versionRootId, versionLabel: "A", versionPrimary: true } } : node),
...nodesRef.current.map((node) => node.id === source.id && versionRootId && !node.metadata?.versionLabel ? { ...node, title: `${node.title} · A`, metadata: { ...node.metadata, versionOfNodeId: versionRootId, versionLabel: "A", versionPrimary: true, generationResultPlacement: "replace-node" as const } } : node),
...copiedNodes,
];
commitNodes(nextNodes);

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { App } from "antd";
import { useNavigate } from "react-router";
@@ -6,12 +6,12 @@ import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasApp
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
type UseCanvasProjectLifecycleOptions = {
@@ -130,12 +130,15 @@ export function useCanvasProjectLifecycle({
const initialSessions = project.chatSessions || [];
// 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。
applyRestoredProject(initialNodes, initialSessions);
const [nodesResult, sessionsResult] = await Promise.allSettled([hydrateCanvasImages(initialNodes), hydrateAssistantImages(initialSessions)]);
if (cancelled) return;
if (nodesResult.status === "fulfilled") setNodes((current) => mergeHydratedNodeMedia(current, initialNodes, nodesResult.value));
if (sessionsResult.status === "fulfilled") setChatSessions((current) => mergeHydratedSessions(current, sessionsResult.value));
if (nodesResult.status === "rejected" || sessionsResult.status === "rejected") message.warning("部分本地媒体恢复失败,已使用项目记录继续打开");
startTransition(() => applyRestoredProject(initialNodes, initialSessions));
// 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。
void hydrateAssistantImages(initialSessions)
.then((hydratedSessions) => {
if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions));
})
.catch(() => {
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
});
};
void restore();
return () => {
@@ -252,24 +255,6 @@ export function useCanvasProjectLifecycle({
};
}
const hydratedMediaMetadataKeys = ["content", "storageKey", "naturalWidth", "naturalHeight", "bytes", "mimeType", "durationMs", "hasAudio", "videoPreview"] as const satisfies readonly (keyof CanvasNodeMetadata)[];
function mergeHydratedNodeMedia(currentNodes: CanvasNodeData[], initialNodes: CanvasNodeData[], hydratedNodes: CanvasNodeData[]) {
const initialById = new Map(initialNodes.map((node) => [node.id, node]));
const hydratedById = new Map(hydratedNodes.map((node) => [node.id, node]));
return currentNodes.map((node) => {
const initial = initialById.get(node.id);
const hydrated = hydratedById.get(node.id);
if (!initial || !hydrated || node.metadata?.content !== initial.metadata?.content) return node;
const metadata = { ...node.metadata } as CanvasNodeMetadata;
hydratedMediaMetadataKeys.forEach((key) => {
const value = hydrated.metadata?.[key];
if (value !== undefined) (metadata as Record<string, unknown>)[key] = value;
});
return { ...node, metadata };
});
}
function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) {
const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session]));
return currentSessions.map((session) => {

View File

@@ -1,15 +1,16 @@
import { useMemo, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain";
import { shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index";
import type { Skill } from "@/services/api/skills";
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
import type { DirectorScene } from "@/types/director";
import { CanvasNodeType, type CanvasConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasDisplayConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
@@ -74,26 +75,65 @@ export function useCanvasRenderModel({
}: UseCanvasRenderModelOptions) {
const reduceMediaEffects = useMemo(() => shouldReduceCanvasMediaEffects(mediaPerformanceMode, nodes), [mediaPerformanceMode, nodes]);
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const collapsedBatchChildIds = useMemo(() => {
const hidden = new Set<string>();
nodes.forEach((node) => {
// These maps are consumed by both virtualization and node chrome. Building
// them together keeps a metadata update from walking a 50k-node array six
// separate times before React can paint the next frame.
const nodeDerivedData = useMemo(() => {
const collapsedBatchChildIds = new Set<string>();
const renderHiddenNodeIds = new Set<string>();
const frameChildrenById = new Map<string, CanvasNodeData[]>();
const canvasImageNodes: CanvasNodeData[] = [];
const batchRoots: CanvasNodeData[] = [];
const batchMotionById = new Map<string, { x: number; y: number; index: number }>();
const batchChildIndexByRootId = new Map<string, Map<string, number>>();
for (const node of nodes) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return;
const root = rootId ? nodeById.get(rootId) : undefined;
if (root && !root.metadata?.imageBatchExpanded) {
collapsedBatchChildIds.add(node.id);
if (!collapsingBatchIds.has(root.id)) renderHiddenNodeIds.add(node.id);
}
if (rootId && collapsingBatchIds.has(rootId)) renderHiddenNodeIds.delete(node.id);
const parent = node.parentId ? nodeById.get(node.parentId) : undefined;
if (parent && isFrameNode(parent)) {
const children = frameChildrenById.get(parent.id);
if (children) children.push(node);
else frameChildrenById.set(parent.id, [node]);
if (parent.metadata?.frame?.collapsed) renderHiddenNodeIds.add(node.id);
}
if (node.metadata?.isBatchRoot) batchRoots.push(node);
if (node.type === CanvasNodeType.Image && node.metadata?.content && !collapsedBatchChildIds.has(node.id) && !(parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed)) {
canvasImageNodes.push(node);
}
}
for (const root of batchRoots) {
const childIndex = new Map((root.metadata?.batchChildIds || []).map((childId, index) => [childId, index]));
batchChildIndexByRootId.set(root.id, childIndex);
}
for (const node of nodes) {
const rootId = node.metadata?.batchRootId;
if (!rootId) continue;
const root = nodeById.get(rootId);
if (root && !root.metadata?.imageBatchExpanded) hidden.add(node.id);
});
return hidden;
}, [nodeById, nodes]);
const renderHiddenNodeIds = useMemo(() => {
const hidden = new Set(collapsedBatchChildIds);
nodes.forEach((node) => {
const rootId = node.metadata?.batchRootId;
if (rootId && collapsingBatchIds.has(rootId)) hidden.delete(node.id);
const parent = node.parentId ? nodeById.get(node.parentId) : null;
if (parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed) hidden.add(node.id);
});
return hidden;
}, [collapsedBatchChildIds, collapsingBatchIds, nodeById, nodes]);
const index = batchChildIndexByRootId.get(rootId)?.get(node.id) ?? 0;
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
batchMotionById.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
}
const batchChildCountById = new Map<string, number>();
for (const root of batchRoots) {
const childIndex = batchChildIndexByRootId.get(root.id);
const liveChildCount = [...(childIndex?.keys() || [])].filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === root.id).length;
batchChildCountById.set(root.id, liveChildCount);
}
return { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds };
}, [collapsingBatchIds, nodeById, nodes]);
const { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds } = nodeDerivedData;
const connectionLayerBounds = useMemo(() => {
const padding = (reduceMediaEffects ? 96 : 144) / Math.max(viewport.k, 0.05);
const left = -viewport.x / viewport.k - padding;
@@ -103,27 +143,70 @@ export function useCanvasRenderModel({
return { left, top, width: Math.max(2, width), height: Math.max(2, height) };
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const renderBounds = useMemo(() => {
const padding = (reduceMediaEffects ? 128 : 192) / viewport.k;
const viewLeft = -viewport.x / viewport.k - padding;
const viewTop = -viewport.y / viewport.k - padding;
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
const viewBottom = viewTop + viewportSize.height / viewport.k + padding * 2;
return { left: viewLeft, top: viewTop, right: viewRight, bottom: viewBottom };
const enterPadding = canvasNodeRenderPadding(reduceMediaEffects, false) / viewport.k;
const retainPadding = canvasNodeRenderPadding(reduceMediaEffects, true) / viewport.k;
const viewLeft = -viewport.x / viewport.k;
const viewTop = -viewport.y / viewport.k;
const viewWidth = viewportSize.width / viewport.k;
const viewHeight = viewportSize.height / viewport.k;
return {
enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding },
retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding },
};
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const nodeSpatialIndexRef = useRef<{ source: CanvasNodeData[]; index: CanvasSpatialIndex<string> } | null>(null);
const nodeSpatialIndex = useMemo(() => {
const previous = nodeSpatialIndexRef.current;
const geometryUnchanged = previous
&& previous.source.length === nodes.length
&& nodes.every((node, index) => {
const old = previous.source[index];
return old.id === node.id
&& old.position.x === node.position.x
&& old.position.y === node.position.y
&& old.width === node.width
&& old.height === node.height;
});
if (geometryUnchanged) return previous.index;
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node.id })));
nodeSpatialIndexRef.current = { source: nodes, index };
return index;
}, [nodes]);
const renderedNodeIdsRef = useRef<Set<string>>(new Set());
const visibleNodes = useMemo(() => {
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
nodes.forEach((node) => {
const renderedNodeIds = renderedNodeIdsRef.current;
const renderBudget = canvasNodeRenderBudget(viewport.k);
const forcedNodeIds = new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])].slice(0, renderBudget));
const candidates = nodeSpatialIndex.query(renderBounds.retain, renderBudget + forcedNodeIds.size)
.map((nodeId) => nodeById.get(nodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const candidateIds = new Set(candidates.map((node) => node.id));
for (const nodeId of forcedNodeIds) {
if (candidateIds.has(nodeId)) continue;
const node = nodeById.get(nodeId);
if (node) candidates.push(node);
}
const prioritized = candidates.filter((node) => forcedNodeIds.has(node.id));
const remaining = candidates.filter((node) => !forcedNodeIds.has(node.id)).slice(0, Math.max(0, renderBudget - prioritized.length));
[...prioritized, ...remaining].forEach((node) => {
if (renderHiddenNodeIds.has(node.id)) return;
const retained = selectedNodeIds.has(node.id) || Boolean(dragPreview?.nodeIds.has(node.id));
if (!retained && (node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom)) return;
const retained = forcedNodeIds.has(node.id) || renderedNodeIds.has(node.id);
const insideEnterBounds = node.position.x + node.width > renderBounds.enter.left
&& node.position.x < renderBounds.enter.right
&& node.position.y + node.height > renderBounds.enter.top
&& node.position.y < renderBounds.enter.bottom;
if (!retained && !insideEnterBounds) return;
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [dragPreview, nodes, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
useEffect(() => {
renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id));
}, [visibleNodes]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
const semanticNodesRef = useRef(nodes);
const semanticNodes = useMemo(() => {
const previous = semanticNodesRef.current;
@@ -156,53 +239,24 @@ export function useCanvasRenderModel({
const selectedNodeBounds = useMemo(() => {
if (selectedNodeIds.size < 2) return null;
const selectedNodes = nodes.filter((node) => selectedNodeIds.has(node.id) && !renderHiddenNodeIds.has(node.id));
const selectedNodes = [...selectedNodeIds]
.map((nodeId) => nodeById.get(nodeId))
.filter((node): node is CanvasNodeData => Boolean(node && !renderHiddenNodeIds.has(node.id)));
if (selectedNodes.length < 2) return null;
const left = Math.min(...selectedNodes.map((node) => node.position.x));
const top = Math.min(...selectedNodes.map((node) => node.position.y));
const right = Math.max(...selectedNodes.map((node) => node.position.x + node.width));
const bottom = Math.max(...selectedNodes.map((node) => node.position.y + node.height));
return { left, top, width: right - left, height: bottom - top, count: selectedNodes.length };
}, [nodes, renderHiddenNodeIds, selectedNodeIds]);
const selectedVideoNodes = useMemo(() => nodes
.filter((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Video && Boolean(node.metadata?.content) && !renderHiddenNodeIds.has(node.id))
}, [nodeById, renderHiddenNodeIds, selectedNodeIds]);
const selectedVideoNodes = useMemo(() => [...selectedNodeIds]
.map((nodeId) => nodeById.get(nodeId))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Video && node.metadata?.content && !renderHiddenNodeIds.has(node.id)))
.sort((a, b) => {
const shotA = a.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
const shotB = b.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
return shotA - shotB || a.position.y - b.position.y || a.position.x - b.position.x;
}), [nodes, renderHiddenNodeIds, selectedNodeIds]);
const batchChildCountById = useMemo(() => {
const map = new Map<string, number>();
nodes.forEach((node) => {
if (!node.metadata?.isBatchRoot) return;
const liveChildCount = (node.metadata.batchChildIds || []).filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === node.id).length;
map.set(node.id, liveChildCount);
});
return map;
}, [nodeById, nodes]);
const frameChildrenById = useMemo(() => {
const map = new Map<string, CanvasNodeData[]>();
nodes.forEach((node) => {
if (!node.parentId) return;
const children = map.get(node.parentId) || [];
children.push(node);
map.set(node.parentId, children);
});
return map;
}, [nodes]);
const batchMotionById = useMemo(() => {
const map = new Map<string, { x: number; y: number; index: number }>();
nodes.forEach((node) => {
const rootId = node.metadata?.batchRootId;
if (!rootId) return;
const root = nodeById.get(rootId);
const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0;
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
});
return map;
}, [nodeById, nodes]);
}), [nodeById, renderHiddenNodeIds, selectedNodeIds]);
const relatedHighlight = useMemo(() => {
const nodeIds = new Set<string>();
const connectionIds = new Set<string>();
@@ -216,45 +270,89 @@ export function useCanvasRenderModel({
});
return { nodeIds, connectionIds };
}, [activeNodeId, connections]);
const displayConnections = useMemo(() => connections.flatMap((connection) => {
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return [];
const fromNode = nodeById.get(connection.fromNodeId);
const toNode = nodeById.get(connection.toNodeId);
if (!fromNode || !toNode) return [];
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
if (displayFrom.id === displayTo.id) return [];
const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
return [{ connection, from, to }];
}), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]);
const connectionSpatialIndex = useMemo(() => {
const entries: CanvasSpatialIndexEntry<CanvasDisplayConnection>[] = [];
const connectionIdsByNodeId = new Map<string, Set<string>>();
connections.forEach((connection) => {
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return;
const fromNode = nodeById.get(connection.fromNodeId);
const toNode = nodeById.get(connection.toNodeId);
if (!fromNode || !toNode) return;
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
if (displayFrom.id === displayTo.id) return;
const left = Math.min(displayFrom.position.x, displayTo.position.x);
const top = Math.min(displayFrom.position.y, displayTo.position.y);
const right = Math.max(displayFrom.position.x + displayFrom.width, displayTo.position.x + displayTo.width);
const bottom = Math.max(displayFrom.position.y + displayFrom.height, displayTo.position.y + displayTo.height);
const value = { connection, from: displayFrom, to: displayTo };
entries.push({ id: connection.id, bounds: { left, top, right, bottom }, value });
for (const nodeId of new Set([connection.fromNodeId, connection.toNodeId, displayFrom.id, displayTo.id])) {
const ids = connectionIdsByNodeId.get(nodeId) || new Set<string>();
ids.add(connection.id);
connectionIdsByNodeId.set(nodeId, ids);
}
});
return { index: buildCanvasSpatialIndex(entries), connectionIdsByNodeId, entriesById: new Map(entries.map((entry) => [entry.id, entry.value])) };
}, [collapsedBatchChildIds, connections, nodeById]);
const displayConnections = useMemo(() => {
const candidateById = new Map<string, CanvasDisplayConnection>();
connectionSpatialIndex.index.query(renderBounds.retain, CANVAS_MAX_RENDERED_CONNECTIONS).forEach((display) => candidateById.set(display.connection.id, display));
dragPreview?.nodeIds.forEach((nodeId) => {
connectionSpatialIndex.connectionIdsByNodeId.get(nodeId)?.forEach((connectionId) => {
const display = connectionSpatialIndex.entriesById.get(connectionId);
if (display) candidateById.set(connectionId, display);
});
});
return [...candidateById.values()].flatMap(({ connection, from: sourceFrom, to: sourceTo }) => {
const from = dragPreview?.nodeIds.has(sourceFrom.id) ? { ...sourceFrom, position: { x: sourceFrom.position.x + dragPreview.x, y: sourceFrom.position.y + dragPreview.y } } : sourceFrom;
const to = dragPreview?.nodeIds.has(sourceTo.id) ? { ...sourceTo, position: { x: sourceTo.position.x + dragPreview.x, y: sourceTo.position.y + dragPreview.y } } : sourceTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.retain.left || connectionLeft >= renderBounds.retain.right || connectionBottom <= renderBounds.retain.top || connectionTop >= renderBounds.retain.bottom) return [];
return [{ connection, from, to }];
});
}, [connectionSpatialIndex, dragPreview, renderBounds]);
const configInputsById = useMemo(() => {
const map = new Map<string, NodeGenerationInput[]>();
semanticNodes.forEach((node) => {
if (node.type === CanvasNodeType.Config) map.set(node.id, buildNodeGenerationInputs(node.id, semanticNodes, connections));
const configNodeIds = new Set<string>();
visibleNodes.forEach((node) => {
if (node.type === CanvasNodeType.Config) configNodeIds.add(node.id);
});
selectedNodeIds.forEach((nodeId) => {
if (nodeById.get(nodeId)?.type === CanvasNodeType.Config) configNodeIds.add(nodeId);
});
if (dialogNodeId && nodeById.get(dialogNodeId)?.type === CanvasNodeType.Config) configNodeIds.add(dialogNodeId);
configNodeIds.forEach((nodeId) => map.set(nodeId, buildNodeGenerationInputs(nodeId, semanticNodes, connections)));
return map;
}, [connections, semanticNodes]);
}, [connections, dialogNodeId, nodeById, selectedNodeIds, semanticNodes, visibleNodes]);
const activeDirectorNode = useMemo(() => semanticNodes.find((node) => node.id === directorNodeId) || null, [directorNodeId, semanticNodes]);
const activeStylePresetId = useMemo(() => semanticNodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.stylePresetId, [semanticNodes]);
const activeScriptNode = useMemo(() => semanticNodes.find((node) => node.id === scriptEditorNodeId && node.type === CanvasNodeType.Script) || null, [scriptEditorNodeId, semanticNodes]);
const activeDirectorScene = useMemo(() => directorScenes?.find((scene) => scene.id === activeDirectorNode?.metadata?.directorSceneId) || null, [activeDirectorNode?.metadata?.directorSceneId, directorScenes]);
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId), [activeNodeId, connections, dialogNodeId, semanticNodes]);
const resourceReferenceTargetNodes = useMemo(() => {
const targetNodes = [...visibleNodes];
const activeId = dialogNodeId || activeNodeId;
if (activeId) {
const activeNode = nodeById.get(activeId);
if (activeNode) targetNodes.push(activeNode);
}
return targetNodes;
}, [activeNodeId, dialogNodeId, nodeById, visibleNodes]);
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId, resourceReferenceTargetNodes), [activeNodeId, connections, dialogNodeId, resourceReferenceTargetNodes, semanticNodes]);
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]);
const mentionReferencesByNodeId = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildNodeMentionReferences>>();
semanticNodes.forEach((node) => map.set(node.id, [...buildNodeMentionReferences(node, semanticNodes, connections), ...skillMentionReferences]));
const map = buildCanvasNodeMentionReferenceMap(semanticNodes, connections, visibleNodes);
if (!skillMentionReferences.length) return map;
map.forEach((references, nodeId) => map.set(nodeId, [...references, ...skillMentionReferences]));
return map;
}, [connections, semanticNodes, skillMentionReferences]);
}, [connections, semanticNodes, skillMentionReferences, visibleNodes]);
return {
activeDirectorNode,

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type SetStateAction } from "react";
import { applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateNodeAlignment, createNodeAlignmentContext, isHiddenBatchChild, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain";
import { applyFrameDrop, findFrameDropTarget, getFrameChildIds, isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
import { applyCanvasNodeDragPreview, applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateNodeAlignment, createNodeAlignmentContext, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain";
import { applyFrameDrop, buildCanvasFrameDropIndex, findFrameDropTargetFromIndex, getFrameChildIds, isFrameNode } from "@/lib/canvas/canvas-frame";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
import type { CanvasNodeData, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
type UseCanvasSelectionControllerOptions = {
@@ -34,6 +35,8 @@ type DragState = {
startX: number;
startY: number;
draggedNodeIds: string[];
draggedRenderNodeIds: string[];
draggedRenderNodeIdSet: Set<string>;
initialSelectedNodes: Array<{ id: string; x: number; y: number }>;
};
@@ -44,6 +47,8 @@ const EMPTY_DRAG_STATE: DragState = {
startX: 0,
startY: 0,
draggedNodeIds: [],
draggedRenderNodeIds: [],
draggedRenderNodeIdSet: new Set(),
initialSelectedNodes: [],
};
@@ -75,12 +80,14 @@ export function useCanvasSelectionController({
const lastFrameDropCheckRef = useRef(0);
const selectionFrameRef = useRef<number | null>(null);
const selectionBoundsElementRef = useRef<HTMLDivElement>(null);
const selectionCandidatesRef = useRef<Array<{ id: string; left: number; top: number; right: number; bottom: number }>>([]);
const selectionSpatialIndexRef = useRef<CanvasSpatialIndex<CanvasNodeData>>(buildCanvasSpatialIndex([]));
const pendingSelectionPointRef = useRef<Position | null>(null);
const selectionActivatedRef = useRef(false);
const selectionBoxRef = useRef<SelectionBox | null>(null);
const nodeDraggingRef = useRef(false);
const dragRef = useRef<DragState>({ ...EMPTY_DRAG_STATE });
const frameDropIndexRef = useRef(buildCanvasFrameDropIndex([]));
const draggedNodesRef = useRef<CanvasNodeData[]>([]);
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const [frameDropTargetId, setFrameDropTargetId] = useState<string | null>(null);
const [isNodeDragging, setIsNodeDragging] = useState(false);
@@ -89,7 +96,6 @@ export function useCanvasSelectionController({
const cancelSelectionBox = useCallback(() => {
selectionBoxRef.current = null;
selectionCandidatesRef.current = [];
pendingSelectionPointRef.current = null;
selectionActivatedRef.current = false;
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
@@ -125,9 +131,16 @@ export function useCanvasSelectionController({
};
selectionBoxRef.current = nextSelectionBox;
selectionActivatedRef.current = false;
selectionCandidatesRef.current = nodesRef.current
.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current))
.map((node) => ({ id: node.id, left: node.position.x, top: node.position.y, right: node.position.x + node.width, bottom: node.position.y + node.height }));
const currentNodes = nodesRef.current;
const nodeById = new Map(currentNodes.map((node) => [node.id, node]));
const hiddenBatchChildIds = new Set(currentNodes.flatMap((node) => {
const rootId = node.metadata?.batchRootId;
const root = rootId ? nodeById.get(rootId) : undefined;
return root && !root.metadata?.imageBatchExpanded ? [node.id] : [];
}));
selectionSpatialIndexRef.current = buildCanvasSpatialIndex(currentNodes
.filter((node) => !hiddenBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed))
.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node })));
setSelectedConnectionId(null);
}, [cancelPendingConnectionCreate, nodesRef, onCanvasSelectionStart, screenToCanvas, selectedNodeIdsRef, setSelectedConnectionId]);
@@ -181,7 +194,11 @@ export function useCanvasSelectionController({
const initialSelectedNodes = currentNodes.filter((node) => dragIds.has(node.id) && !node.metadata?.locked).map((node) => ({ id: node.id, x: node.position.x, y: node.position.y }));
if (!initialSelectedNodes.length) return;
dragRef.current = { isDraggingNode: true, hasMoved: false, openPanelOnClick: !isMultiSelectClick, startX: event.clientX, startY: event.clientY, draggedNodeIds, initialSelectedNodes };
frameDropIndexRef.current = buildCanvasFrameDropIndex(currentNodes);
draggedNodesRef.current = currentNodes.filter((node) => dragIds.has(node.id) && !node.metadata?.locked);
const draggedRenderNodeIds = initialSelectedNodes.map((item) => item.id);
const draggedRenderNodeIdSet = new Set(draggedRenderNodeIds);
dragRef.current = { isDraggingNode: true, hasMoved: false, openPanelOnClick: !isMultiSelectClick, startX: event.clientX, startY: event.clientY, draggedNodeIds, draggedRenderNodeIds, draggedRenderNodeIdSet, initialSelectedNodes };
historyPausedRef.current = true;
nodeDraggingRef.current = true;
pendingNodeDragRef.current = { x: 0, y: 0 };
@@ -189,8 +206,9 @@ export function useCanvasSelectionController({
lastFrameDropCheckRef.current = 0;
setIsNodeDragging(true);
setAlignmentGuides({});
setDragPreview({ x: 0, y: 0, nodeIds: new Set(initialSelectedNodes.map((item) => item.id)) });
}, [historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeBringToFront, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
setDragPreview({ x: 0, y: 0, nodeIds: draggedRenderNodeIdSet });
applyCanvasNodeDragPreview(containerRef.current, { x: 0, y: 0, nodeIds: draggedRenderNodeIdSet });
}, [containerRef, historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeBringToFront, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => {
if (dragFrameRef.current) {
@@ -209,6 +227,7 @@ export function useCanvasSelectionController({
historyPausedRef.current = false;
nodeDraggingRef.current = false;
applyCanvasNodeDragPreview(containerRef.current, null);
setIsNodeDragging(false);
setDragPreview(null);
setAlignmentGuides({});
@@ -218,7 +237,7 @@ export function useCanvasSelectionController({
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 targetId = findFrameDropTargetFromIndex(frameDropIndexRef.current, draggedNodesRef.current, draggedNodeIds, { x: dx, y: dy });
const target = targetId ? positioned.find((node) => node.id === targetId) : undefined;
const linkedFolder = target?.metadata?.folder?.assetFolderId ? target : undefined;
// 素材库文件夹只建立归档关系,不把画布节点变成其本地子节点。
@@ -228,6 +247,7 @@ export function useCanvasSelectionController({
}
setFrameDropTargetId(null);
alignmentContextRef.current = null;
draggedNodesRef.current = [];
dragRef.current = { ...EMPTY_DRAG_STATE };
if (wasClick && clickedNodeId) {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
@@ -245,23 +265,22 @@ export function useCanvasSelectionController({
const aligned = calculateNodeAlignment(alignmentContextRef.current, pendingNodeDragRef.current, 7 / viewportRef.current.k);
const latest = aligned.offset;
pendingAlignmentGuidesRef.current = aligned.guides;
const initialById = new Map(dragRef.current.initialSelectedNodes.map((item) => [item.id, item]));
const now = performance.now();
if (now - lastFrameDropCheckRef.current >= 100) {
lastFrameDropCheckRef.current = now;
const draggedNodeIds = new Set(dragRef.current.draggedNodeIds);
const positioned = nodesRef.current.map((node) => {
const initial = initialById.get(node.id);
return initial ? { ...node, position: { x: initial.x + latest.x, y: initial.y + latest.y } } : node;
});
setFrameDropTargetId(findFrameDropTarget(positioned, draggedNodeIds));
setFrameDropTargetId(findFrameDropTargetFromIndex(frameDropIndexRef.current, draggedNodesRef.current, draggedNodeIds, latest));
}
setDragPreview((current) => current ? { ...current, x: latest.x, y: latest.y } : current);
applyCanvasNodeDragPreview(containerRef.current, {
x: latest.x,
y: latest.y,
nodeIds: dragRef.current.draggedRenderNodeIdSet,
});
const nextGuides = dragRef.current.hasMoved ? pendingAlignmentGuidesRef.current : {};
setAlignmentGuides((current) => current.vertical === nextGuides.vertical && current.horizontal === nextGuides.horizontal ? current : nextGuides);
dragFrameRef.current = null;
});
}, [nodesRef, viewportRef]);
}, [containerRef, viewportRef]);
const handlePointerMove = useCallback((event: PointerEvent) => {
if (dragRef.current.isDraggingNode) {
@@ -302,8 +321,7 @@ export function useCanvasSelectionController({
selectionBoxRef.current = selection;
applyCanvasSelectionPreview(containerRef.current, selection);
const nextSelected = new Set<string>(selection.additive || selection.subtractive ? selection.initialSelectedNodeIds : []);
selectionCandidatesRef.current.forEach((node) => {
if (rectX >= node.right || rectX + rectW <= node.left || rectY >= node.bottom || rectY + rectH <= node.top) return;
selectionSpatialIndexRef.current.query({ left: rectX, top: rectY, right: rectX + Math.max(rectW, 0.01), bottom: rectY + Math.max(rectH, 0.01) }).forEach((node) => {
if (selection.subtractive) nextSelected.delete(node.id);
else nextSelected.add(node.id);
});
@@ -337,6 +355,7 @@ export function useCanvasSelectionController({
return () => {
if (dragFrameRef.current) cancelAnimationFrame(dragFrameRef.current);
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
applyCanvasNodeDragPreview(containerRef.current, null);
window.removeEventListener("mousemove", handleNodeDragMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("pointermove", handlePointerMove);