diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index d4b172ed..e6b76d29 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -558,6 +558,7 @@ export function WorkflowCanvas() { const addNodeToGroup = useWorkflowStore((state) => state.addNodeToGroup); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow); + const appendWorkflow = useWorkflowStore((state) => state.appendWorkflow); const markAsUnsaved = useWorkflowStore((state) => state.markAsUnsaved); const getNodeById = useWorkflowStore((state) => state.getNodeById); const setNodeGroupId = useWorkflowStore((state) => state.setNodeGroupId); @@ -2052,10 +2053,8 @@ export function WorkflowCanvas() { try { const workflow = JSON.parse(e.target?.result as string) as WorkflowFile; if (workflow.version && workflow.nodes && workflow.edges) { - await loadWorkflow({ - ...workflow, - ...(urlWorkflowId ? { id: urlWorkflowId } : { id: undefined }), - }); + // Append imported nodes to the current workflow (keep current id/name). + appendWorkflow(workflow); markAsUnsaved(); } else { alert(t("workflow.invalidWorkflowFile")); @@ -2168,7 +2167,7 @@ export function WorkflowCanvas() { useToast.getState().show(error instanceof Error ? error.message : "Media upload failed", "error"); }); }); - }, [addNode, loadWorkflow, markAsUnsaved, selectSingleNode, t, updateNodeData, urlWorkflowId]); + }, [addNode, appendWorkflow, markAsUnsaved, selectSingleNode, t, updateNodeData]); const updateLastCanvasPointerPosition = useCallback((event: ReactMouseEvent) => { lastCanvasPointerFlowPositionRef.current = screenToFlowPosition({ @@ -2750,10 +2749,8 @@ export function WorkflowCanvas() { try { const workflow = JSON.parse(e.target?.result as string) as WorkflowFile; if (workflow.version && workflow.nodes && workflow.edges) { - await loadWorkflow({ - ...workflow, - ...(urlWorkflowId ? { id: urlWorkflowId } : { id: undefined }), - }); + // Append imported nodes to the current workflow (keep current id/name). + appendWorkflow(workflow); markAsUnsaved(); } else { alert(t("workflow.invalidWorkflowFile")); @@ -2902,7 +2899,7 @@ export function WorkflowCanvas() { }); }); }, - [screenToFlowPosition, addNode, updateNodeData, loadWorkflow, selectSingleNode, t] + [screenToFlowPosition, addNode, updateNodeData, appendWorkflow, selectSingleNode, t] ); const openCanvasContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => { diff --git a/src/components/media/image-edit/AnnotationEditSession.tsx b/src/components/media/image-edit/AnnotationEditSession.tsx index 54e807eb..07d81828 100644 --- a/src/components/media/image-edit/AnnotationEditSession.tsx +++ b/src/components/media/image-edit/AnnotationEditSession.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { MediaAnnotationOverlay, type MediaAnnotationFrameRect, @@ -52,29 +51,33 @@ export function AnnotationEditSession({ const handleSave = useCallback(() => { if (busy) return; + // 没画任何图形就不建节点,直接关闭,省一次无意义的合成与上传。 + if (annotations.length === 0) { + onClose(); + return; + } setBusy(true); + // 点保存即建 loading 节点(用源图尺寸占位),把"合成 + 上传"整段都盖在 loading 状态下。 + const pending = createAnnotationNode({ dimensions: dimensions ?? null }); + if (!pending) { + setBusy(false); + return; + } void (async () => { + // overlay 仍在,复用其已解码底图本地压平;完成后关闭会话,再后台上传、以实际尺寸回填。 const composite = await overlayRef.current?.exportComposite(); - if (!composite) { - setBusy(false); - return; - } onClose(); - const uploaded = await uploadBlobToGatewayMedia( - composite.blob, - `annotation-${Date.now()}.png`, - "image/png", - "image" + pending.finish( + composite + ? { blob: composite.blob, dimensions: { width: composite.width, height: composite.height } } + : null ); - createAnnotationNode({ - imageUrl: uploaded.url, - dimensions: { width: composite.width, height: composite.height }, - }); })().catch((error) => { - setBusy(false); + onClose(); + pending.finish(null); alert(error instanceof Error ? error.message : "标注保存失败"); }); - }, [busy, createAnnotationNode, onClose]); + }, [busy, annotations.length, createAnnotationNode, dimensions, onClose]); // 键盘:删除选中 / 撤销重做 / Esc 关闭。文字输入聚焦时不拦截,交给输入框自身。 useEffect(() => { diff --git a/src/components/media/image-edit/useImageDerivedNodeActions.ts b/src/components/media/image-edit/useImageDerivedNodeActions.ts index 4c7dc73b..e89bb63f 100644 --- a/src/components/media/image-edit/useImageDerivedNodeActions.ts +++ b/src/components/media/image-edit/useImageDerivedNodeActions.ts @@ -12,7 +12,8 @@ import { defaultNodeDimensions } from "@/constants/nodeDimensions"; import { useWorkflowStore } from "@/store/workflowStore"; import type { SmartImageNodeData } from "@/types"; import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; -import { createPendingDerivedImageNode, createDerivedImageNode } from "@/utils/derivedImageNodes"; +import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes"; +import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { createSplitGridImageNodes } from "@/utils/splitGridNodes"; import { readHighDefinitionSourceImageDimensions } from "@/utils/highDefinitionNodes"; import { deriveOutpaintingOutputDimensions, OUTPAINTING_DEFAULT_PROMPT } from "@/utils/outpaintingNodes"; @@ -43,7 +44,7 @@ interface CreateSplitGridNodesOptions { } interface CreateAnnotationNodeOptions { - imageUrl: string; + // 建 loading 占位节点时的尺寸(用源图尺寸即可),最终以合成结果的实际尺寸回填。 dimensions: { width: number; height: number } | null; } @@ -61,6 +62,8 @@ export function useImageDerivedNodeActions({ const getNodeById = useWorkflowStore((state) => state.getNodeById); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const updateMediaNodeData = useWorkflowStore((state) => state.updateMediaNodeData); const getSourcePlacement = useCallback((): { position: XYPosition; dimensions: { width: number; height: number } | null } | null => { const sourceNode = getNodeById(sourceNodeId); @@ -196,26 +199,65 @@ export function useImageDerivedNodeActions({ return createdNodeIds; }, [addNode, getNodeById, onConnect, regenerateNode, selectSingleNode, sourceNodeId]); - // 标注:合成图已在 overlay 里压平并上传,这里直接建带图的派生节点、连边选中。 - // 刻意不调用 regenerateNode —— 不触发任何生成/组运行。 - const createAnnotationNode = useCallback(({ imageUrl, dimensions }: CreateAnnotationNodeOptions) => { + // 标注是一次性任务:节点在点保存的瞬间就以 loading 建出并连边选中,把"合成 + 上传" + // 整段都盖在 loading 状态下。调用方拿到 finish 回调:合成成功传入 blob 后台上传、完成填图; + // 合成失败传入 null 则置为 error。刻意不进 executor、不序列化矢量形状(与 useAnnotationEditor + // 的 session 作用域设计一致),也不重跑。 + const createAnnotationNode = useCallback(({ dimensions }: CreateAnnotationNodeOptions) => { const placement = getSourcePlacement(); if (!placement) return null; const size = dimensions ? resolveMediaNodeCreationSizeFromDimensions(dimensions) ?? undefined : undefined; - return createDerivedImageNode({ - sourceNodeId, - position: placement.position, - image: imageUrl, - filename: `annotation-${Date.now()}.png`, + const filename = `annotation-${Date.now()}.png`; + const nodeId = addNode("smartImage", placement.position, { + image: null, + imageRef: undefined, + assetId: undefined, + filename, dimensions, customTitle: "标注", - operation: "annotate", - size, - addNode, - onConnect, - selectSingleNode, + derivedImage: { + sourceNodeId, + operation: "annotate", + }, + status: "loading", + error: null, + } satisfies Partial, size); + + onConnect({ + source: sourceNodeId, + sourceHandle: SINGLE_OUTPUT_HANDLE_ID, + target: nodeId, + targetHandle: SINGLE_INPUT_HANDLE_ID, }); - }, [addNode, getSourcePlacement, onConnect, selectSingleNode, sourceNodeId]); + selectSingleNode(nodeId); + + const finish = (result: { blob: Blob; dimensions: { width: number; height: number } } | null) => { + if (!result) { + updateNodeData(nodeId, { status: "error", error: "标注合成失败" }); + return; + } + void (async () => { + try { + const uploaded = await uploadBlobToGatewayMedia(result.blob, filename, "image/png", "image"); + updateMediaNodeData(nodeId, { + image: uploaded.url, + imageRef: undefined, + assetId: undefined, + dimensions: result.dimensions, + status: "complete", + error: null, + }, result.dimensions); + } catch (error) { + updateNodeData(nodeId, { + status: "error", + error: error instanceof Error ? error.message : "标注上传失败", + }); + } + })(); + }; + + return { nodeId, finish }; + }, [addNode, getSourcePlacement, onConnect, selectSingleNode, sourceNodeId, updateMediaNodeData, updateNodeData]); return { createCropNode, diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index d874463b..ca2ba47f 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -2764,4 +2764,162 @@ describe("workflowStore integration tests", () => { .toEqual(["https://example.com/source.mp4"]); }); }); + + describe("appendWorkflow", () => { + it("appends imported nodes while preserving the current workflow identity", () => { + useWorkflowStore.setState({ + nodes: [createTestNode("imageInput-1", "imageInput", { image: "existing" })], + edges: [], + groups: {}, + workflowId: "wf-current", + workflowName: "Current", + }); + + useWorkflowStore.getState().appendWorkflow({ + version: 1, + id: "other-workflow", + name: "Other", + nodes: [createTestNode("prompt-1", "prompt", { prompt: "imported" })], + edges: [], + edgeStyle: "curved", + }); + + const state = useWorkflowStore.getState(); + expect(state.nodes).toHaveLength(2); + // Current workflow identity is untouched. + expect(state.workflowId).toBe("wf-current"); + expect(state.workflowName).toBe("Current"); + expect(state.hasUnsavedChanges).toBe(true); + // Existing node survives. + expect(state.nodes.some((n) => n.id === "imageInput-1")).toBe(true); + }); + + it("remaps colliding ids so re-importing the same workflow never duplicates keys", () => { + useWorkflowStore.setState({ + nodes: [createTestNode("imageInput-1", "imageInput", { image: "existing" })], + edges: [], + groups: {}, + }); + + useWorkflowStore.getState().appendWorkflow({ + version: 1, + name: "Self", + nodes: [ + createTestNode("imageInput-1", "imageInput", { image: "imported" }), + createTestNode("prompt-1", "prompt", { prompt: "p" }), + ], + edges: [createTestEdge("prompt-1", "imageInput-1", "output", "input")], + edgeStyle: "curved", + }); + + const state = useWorkflowStore.getState(); + const ids = state.nodes.map((n) => n.id); + // No duplicate ids. + expect(new Set(ids).size).toBe(ids.length); + expect(state.nodes).toHaveLength(3); + // Appended edge references only existing node ids. + const nodeIdSet = new Set(ids); + expect(state.edges).toHaveLength(1); + expect(nodeIdSet.has(state.edges[0].source)).toBe(true); + expect(nodeIdSet.has(state.edges[0].target)).toBe(true); + }); + + it("remaps referenceSubjectList source-node ids to the appended nodes", () => { + useWorkflowStore.setState({ nodes: [], edges: [], groups: {} }); + + useWorkflowStore.getState().appendWorkflow({ + version: 1, + name: "Refs", + nodes: [ + createTestNode("imageInput-1", "imageInput", { image: "u" }), + createTestNode("nanoBanana-1", "nanoBanana", { + prompt: "p", + selectedModel: { provider: "popiserver", modelId: "m", displayName: "M" }, + referenceSubjectList: [ + { id: "imageInput-1", type: "image", url: "u", name: "n" }, + ], + }), + ], + edges: [createTestEdge("imageInput-1", "nanoBanana-1", "output", "input")], + edgeStyle: "curved", + }); + + const state = useWorkflowStore.getState(); + const imageNode = state.nodes.find((n) => n.type === "imageInput"); + const genNode = state.nodes.find((n) => n.type === "nanoBanana"); + expect(imageNode).toBeDefined(); + expect(genNode).toBeDefined(); + const subjectId = (genNode!.data as { referenceSubjectList: { id: string }[] }) + .referenceSubjectList[0].id; + // The embedded source-node id now points at the appended image node. + expect(subjectId).toBe(imageNode!.id); + expect(subjectId).not.toBe("imageInput-1"); + }); + + it("offsets appended top-level nodes into empty space to the right", () => { + useWorkflowStore.setState({ + nodes: [ + createTestNode("imageInput-1", "imageInput", { image: "e" }, { x: 0, y: 0 }), + ], + edges: [], + groups: {}, + }); + + useWorkflowStore.getState().appendWorkflow({ + version: 1, + name: "Offset", + nodes: [createTestNode("prompt-1", "prompt", { prompt: "p" }, { x: 0, y: 0 })], + edges: [], + edgeStyle: "curved", + }); + + const appended = useWorkflowStore + .getState() + .nodes.find((n) => n.type === "prompt"); + expect(appended).toBeDefined(); + // Placed to the right of the existing node rather than overlapping at x=0. + expect(appended!.position.x).toBeGreaterThan(0); + }); + + it("is undoable in a single step", () => { + useWorkflowStore.setState({ + nodes: [createTestNode("imageInput-1", "imageInput", { image: "existing" })], + edges: [], + groups: {}, + }); + + useWorkflowStore.getState().appendWorkflow({ + version: 1, + name: "Undo", + nodes: [createTestNode("prompt-1", "prompt", { prompt: "p" })], + edges: [], + edgeStyle: "curved", + }); + expect(useWorkflowStore.getState().nodes).toHaveLength(2); + + useWorkflowStore.getState().undo(); + + const state = useWorkflowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe("imageInput-1"); + }); + + it("does nothing for an empty import", () => { + useWorkflowStore.setState({ + nodes: [createTestNode("imageInput-1", "imageInput", { image: "existing" })], + edges: [], + groups: {}, + }); + + useWorkflowStore.getState().appendWorkflow({ + version: 1, + name: "Empty", + nodes: [], + edges: [], + edgeStyle: "curved", + }); + + expect(useWorkflowStore.getState().nodes).toHaveLength(1); + }); + }); }); diff --git a/src/store/utils/__tests__/remapWorkflowIds.test.ts b/src/store/utils/__tests__/remapWorkflowIds.test.ts new file mode 100644 index 00000000..1bc28565 --- /dev/null +++ b/src/store/utils/__tests__/remapWorkflowIds.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { remapNodeDataIds } from "../remapWorkflowIds"; + +describe("remapNodeDataIds", () => { + const nodeMap = new Map([ + ["imageInput-1", "imageInput-10"], + ["video-1", "video-20"], + ["video-2", "video-21"], + ]); + const edgeMap = new Map([ + ["edge-video-1-target-default-default", "edge-video-20-target-default-default"], + ["edge-video-2-target-default-default", "edge-video-21-target-default-default"], + ]); + + it("remaps referenceSubjectList[].id via the node mapping", () => { + const data = { + prompt: "p", + referenceSubjectList: [ + { id: "imageInput-1", type: "image", url: "u", name: "n" }, + { id: "external-99", type: "image", url: "u2", name: "n2" }, + ], + }; + const out = remapNodeDataIds("nanoBanana", data, nodeMap, edgeMap) as typeof data; + expect(out.referenceSubjectList[0].id).toBe("imageInput-10"); + // Unmapped ids are preserved untouched. + expect(out.referenceSubjectList[1].id).toBe("external-99"); + }); + + it("remaps derivedImage / derivedVideo / derivedMedia sourceNodeId", () => { + const image = remapNodeDataIds( + "imageInput", + { derivedImage: { sourceNodeId: "imageInput-1", operation: "crop" } }, + nodeMap, + edgeMap + ) as { derivedImage: { sourceNodeId: string } }; + expect(image.derivedImage.sourceNodeId).toBe("imageInput-10"); + + const media = remapNodeDataIds( + "videoInput", + { derivedMedia: { sourceNodeId: "video-1", operation: "videoFrame" } }, + nodeMap, + edgeMap + ) as { derivedMedia: { sourceNodeId: string } }; + expect(media.derivedMedia.sourceNodeId).toBe("video-20"); + }); + + it("remaps videoStitch clips (node + edge ids) and clipOrder (edge ids)", () => { + const data = { + clips: [ + { + edgeId: "edge-video-1-target-default-default", + sourceNodeId: "video-1", + duration: 1, + handleId: "video-0", + }, + { + edgeId: "edge-video-2-target-default-default", + sourceNodeId: "video-2", + duration: 2, + handleId: "video-1", + }, + ], + clipOrder: [ + "edge-video-1-target-default-default", + "edge-video-2-target-default-default", + ], + }; + const out = remapNodeDataIds("videoStitch", data, nodeMap, edgeMap) as typeof data; + expect(out.clips[0].sourceNodeId).toBe("video-20"); + expect(out.clips[0].edgeId).toBe("edge-video-20-target-default-default"); + expect(out.clips[1].sourceNodeId).toBe("video-21"); + expect(out.clipOrder).toEqual([ + "edge-video-20-target-default-default", + "edge-video-21-target-default-default", + ]); + }); + + it("remaps easeCurve inheritedFrom", () => { + const out = remapNodeDataIds( + "easeCurve", + { inheritedFrom: "video-1" }, + nodeMap, + edgeMap + ) as { inheritedFrom: string }; + expect(out.inheritedFrom).toBe("video-20"); + }); + + it("does not mutate the input object", () => { + const data = { + referenceSubjectList: [{ id: "imageInput-1", type: "image", url: "u", name: "n" }], + }; + remapNodeDataIds("nanoBanana", data, nodeMap, edgeMap); + expect(data.referenceSubjectList[0].id).toBe("imageInput-1"); + }); + + it("returns primitives / empty data unchanged", () => { + expect(remapNodeDataIds("prompt", { prompt: "hi" }, nodeMap, edgeMap)).toEqual({ + prompt: "hi", + }); + expect(remapNodeDataIds("prompt", null as unknown as object, nodeMap, edgeMap)).toBeNull(); + }); +}); diff --git a/src/store/utils/remapWorkflowIds.ts b/src/store/utils/remapWorkflowIds.ts new file mode 100644 index 00000000..87c8d1fe --- /dev/null +++ b/src/store/utils/remapWorkflowIds.ts @@ -0,0 +1,82 @@ +import type { NodeType } from "@/types/nodes"; + +/** + * Rewrite node-id / edge-id references that live *inside* a node's `data` payload. + * + * When a workflow fragment is duplicated (paste) or appended (drag-drop import), + * every node gets a fresh id and every edge gets a fresh id. The top-level + * `source`/`target`/`parentId`/`groupId` references are handled by the caller, + * but several node types also embed ids deep in their `data`: + * + * - referenceSubjectList[].id -> source node id (nanoBanana / generateVideo / generate3d / generateAudio + smart variants) + * - derivedImage.sourceNodeId -> source node id (imageInput / smartImage) + * - derivedVideo.sourceNodeId -> source node id (videoInput / smartVideo) + * - derivedMedia.sourceNodeId -> source node id (image/video input) + * - clips[].sourceNodeId -> source node id (videoStitch) + * - clips[].edgeId / clipOrder[] -> edge id (videoStitch) + * - inheritedFrom -> source node id (easeCurve) + * + * A reference is only rewritten when the mapping contains its key; otherwise the + * original value is kept untouched (so references that point outside the fragment + * survive unchanged). + * + * The function is field-presence based rather than strictly `type`-driven so that + * smart variants (e.g. `smartImage` = ImageInputNodeData + NanoBananaNodeData) + * are handled uniformly. `type` is accepted for clarity/future use. + * + * Returns a new `data` object; the input is not mutated. + */ +export function remapNodeDataIds( + type: NodeType, + data: T, + nodeIdMapping: Map, + edgeIdMapping: Map +): T { + void type; + if (!data || typeof data !== "object") return data; + + const mapNode = (id: unknown): unknown => + typeof id === "string" && nodeIdMapping.has(id) ? nodeIdMapping.get(id)! : id; + const mapEdge = (id: unknown): unknown => + typeof id === "string" && edgeIdMapping.has(id) ? edgeIdMapping.get(id)! : id; + + const source = data as Record; + const next: Record = { ...source }; + + // referenceSubjectList[].id === source node id + if (Array.isArray(source.referenceSubjectList)) { + next.referenceSubjectList = (source.referenceSubjectList as Array>).map( + (subject) => ({ ...subject, id: mapNode(subject.id) }) + ); + } + + // derivedImage / derivedVideo / derivedMedia -> sourceNodeId + for (const key of ["derivedImage", "derivedVideo", "derivedMedia"] as const) { + const derived = source[key]; + if (derived && typeof derived === "object" && "sourceNodeId" in derived) { + const d = derived as Record; + next[key] = { ...d, sourceNodeId: mapNode(d.sourceNodeId) }; + } + } + + // videoStitch clips: sourceNodeId (node) + edgeId (edge) + if (Array.isArray(source.clips)) { + next.clips = (source.clips as Array>).map((clip) => ({ + ...clip, + sourceNodeId: mapNode(clip.sourceNodeId), + edgeId: mapEdge(clip.edgeId), + })); + } + + // videoStitch clipOrder: array of edge ids + if (Array.isArray(source.clipOrder)) { + next.clipOrder = (source.clipOrder as unknown[]).map((edgeId) => mapEdge(edgeId)); + } + + // easeCurve inheritedFrom: source node id + if (typeof source.inheritedFrom === "string") { + next.inheritedFrom = mapNode(source.inheritedFrom); + } + + return next as T; +} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 076c7e9d..13760f4e 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -59,6 +59,7 @@ import { GROUP_COLOR_ORDER, } from "./utils/nodeDefaults"; import { createDefaultImageModel } from "./utils/defaultImageModel"; +import { remapNodeDataIds } from "./utils/remapWorkflowIds"; import { isHighDefinitionDerivedImageNode } from "@/utils/highDefinitionNodes"; import { isHighDefinitionDerivedVideoNode } from "@/utils/highDefinitionVideoNodes"; import { isVideoBoxEraseDerivedNode, isVideoSmartEraseDerivedNode } from "@/utils/videoEraseNodes"; @@ -386,6 +387,7 @@ interface WorkflowStore { // Save/Load saveWorkflow: (name?: string) => void; loadWorkflow: (workflow: WorkflowFile, workflowPath?: string, options?: { preserveSnapshot?: boolean }) => Promise; + appendWorkflow: (workflow: WorkflowFile) => void; clearWorkflow: () => void; // Helpers @@ -1277,6 +1279,124 @@ function buildGroupAwareClipboard( }; } +/** + * Duplicate a workflow fragment (nodes/edges/groups) with fresh ids and a position + * offset. Shared by clipboard paste and drag-drop workflow append. + * + * Every node and group gets a newly-allocated id (via the module counters), so the + * result never collides with existing canvas content. All id references are + * remapped consistently: + * - group shells / members: `parentId`, `groupId`, group id, group shell id + * - edges: `source`, `target`, and the regenerated edge `id` + * - node `data` internals (referenceSubjectList, derived*, clips, clipOrder, + * inheritedFrom) via {@link remapNodeDataIds} + * + * Group members keep their parent-relative position (the parent shell moves by the + * offset); top-level nodes and groups get the offset applied directly. Returned + * nodes are selection-agnostic — callers set `selected` as needed. + */ +function remapWorkflowFragment( + fragment: { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + groups?: Record; + }, + offset: XYPosition +): { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + groups: Record; + idMapping: Map; +} { + // Give every group a fresh identity so the copy is fully independent. + const sourceGroups = fragment.groups ?? {}; + const groupIdMapping = new Map(); + const newGroups: Record = {}; + Object.entries(sourceGroups).forEach(([oldGroupId, group]) => { + const newGroupId = `group-${++groupIdCounter}`; + groupIdMapping.set(oldGroupId, newGroupId); + newGroups[newGroupId] = { + ...group, + id: newGroupId, + position: { + x: group.position.x + offset.x, + y: group.position.y + offset.y, + }, + }; + }); + + // Map old node ids to new ones. Group shells get the canonical + // `group-node-` id derived from their new group id. + const idMapping = new Map(); + fragment.nodes.forEach((node) => { + if (node.type === "group") { + const oldGroupId = (node.data as { groupId?: string }).groupId; + const newGroupId = oldGroupId ? groupIdMapping.get(oldGroupId) : undefined; + if (newGroupId) { + idMapping.set(node.id, getGroupNodeId(newGroupId)); + return; + } + } + idMapping.set(node.id, `${node.type}-${++nodeIdCounter}`); + }); + + // Rebuild edges first so clip/clipOrder edge-id references can be remapped. + const edgeIdMapping = new Map(); + const newEdges: WorkflowEdge[] = fragment.edges.map((edge) => { + const newId = `edge-${idMapping.get(edge.source)}-${idMapping.get(edge.target)}-${edge.sourceHandle || "default"}-${edge.targetHandle || "default"}`; + edgeIdMapping.set(edge.id, newId); + return { + ...edge, + id: newId, + source: idMapping.get(edge.source)!, + target: idMapping.get(edge.target)!, + }; + }); + + const newNodes: WorkflowNode[] = fragment.nodes.map((node) => { + if (node.type === "group") { + const oldGroupId = (node.data as { groupId?: string }).groupId; + const newGroupId = oldGroupId ? groupIdMapping.get(oldGroupId) : undefined; + if (newGroupId && newGroups[newGroupId]) { + // Rebuild the shell from the already-offset group metadata. + return createGroupNode(newGroups[newGroupId]); + } + } + + const isGroupMember = Boolean( + node.parentId && node.groupId && groupIdMapping.has(node.groupId) + ); + const newParentId = isGroupMember ? idMapping.get(node.parentId!) : undefined; + const newGroupId = isGroupMember ? groupIdMapping.get(node.groupId!) : undefined; + + // Strip any stale group linkage; re-add only the remapped ids for real members. + const { parentId: _parentId, groupId: _groupId, ...rest } = node; + const clonedData = clonePreservingStrings(node.data); + const remappedData = remapNodeDataIds( + node.type as NodeType, + clonedData, + idMapping, + edgeIdMapping + ); + + return { + ...rest, + id: idMapping.get(node.id)!, + // Members keep their parent-relative position (the parent shell already moved + // by the offset). Top-level nodes get the offset applied directly. + position: isGroupMember + ? { x: node.position.x, y: node.position.y } + : { x: node.position.x + offset.x, y: node.position.y + offset.y }, + ...(newParentId ? { parentId: newParentId } : {}), + ...(newGroupId ? { groupId: newGroupId } : {}), + style: clonePreservingStrings(node.style) as WorkflowNode["style"], + data: remappedData, + } as WorkflowNode; + }); + + return { nodes: newNodes, edges: newEdges, groups: newGroups, idMapping }; +} + function getRunningNodeIdsFromTasks(tasks: Record): Set { return new Set(Object.values(tasks).map((task) => task.nodeId)); } @@ -2329,82 +2449,17 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ pushUndoCheckpoint(get, set); - // Give every copied group a fresh identity so the pasted group is fully independent - // of the original (deleting one never touches the other). - const clipboardGroups = clipboard.groups ?? {}; - const groupIdMapping = new Map(); - const newGroups: Record = {}; - Object.entries(clipboardGroups).forEach(([oldGroupId, group]) => { - const newGroupId = `group-${++groupIdCounter}`; - groupIdMapping.set(oldGroupId, newGroupId); - newGroups[newGroupId] = { - ...group, - id: newGroupId, - position: { - x: group.position.x + offset.x, - y: group.position.y + offset.y, - }, - }; - }); - - // Create a mapping from old node IDs to new node IDs. Group shell nodes get the - // canonical `group-node-` id derived from their new group id. - const idMapping = new Map(); - clipboard.nodes.forEach((node) => { - if (node.type === "group") { - const oldGroupId = (node.data as { groupId?: string }).groupId; - const newGroupId = oldGroupId ? groupIdMapping.get(oldGroupId) : undefined; - if (newGroupId) { - idMapping.set(node.id, getGroupNodeId(newGroupId)); - return; - } - } - idMapping.set(node.id, `${node.type}-${++nodeIdCounter}`); - }); - - // Create new nodes with updated IDs and offset positions. - const newNodes: WorkflowNode[] = clipboard.nodes.map((node) => { - if (node.type === "group") { - const oldGroupId = (node.data as { groupId?: string }).groupId; - const newGroupId = oldGroupId ? groupIdMapping.get(oldGroupId) : undefined; - if (newGroupId && newGroups[newGroupId]) { - // Rebuild the shell from the already-offset group metadata. - return { ...createGroupNode(newGroups[newGroupId]), selected: true }; - } - } - - const isGroupMember = Boolean( - node.parentId && node.groupId && groupIdMapping.has(node.groupId) - ); - const newParentId = isGroupMember ? idMapping.get(node.parentId!) : undefined; - const newGroupId = isGroupMember ? groupIdMapping.get(node.groupId!) : undefined; - - // Strip any stale group linkage; re-add only the remapped ids for real members. - const { parentId: _parentId, groupId: _groupId, ...rest } = node; - - return { - ...rest, - id: idMapping.get(node.id)!, - // Members keep their parent-relative position (the parent shell already moved by - // the offset). Top-level nodes get the offset applied directly. - position: isGroupMember - ? { x: node.position.x, y: node.position.y } - : { x: node.position.x + offset.x, y: node.position.y + offset.y }, - ...(newParentId ? { parentId: newParentId } : {}), - ...(newGroupId ? { groupId: newGroupId } : {}), - selected: true, // Select newly pasted nodes - style: clonePreservingStrings(node.style) as WorkflowNode["style"], - data: clonePreservingStrings(node.data), - } as WorkflowNode; - }); - - // Create new edges with updated source/target IDs - const newEdges: WorkflowEdge[] = clipboard.edges.map((edge) => ({ - ...edge, - id: `edge-${idMapping.get(edge.source)}-${idMapping.get(edge.target)}-${edge.sourceHandle || "default"}-${edge.targetHandle || "default"}`, - source: idMapping.get(edge.source)!, - target: idMapping.get(edge.target)!, + // Duplicate the clipboard fragment with fresh ids and the paste offset. + const remapped = remapWorkflowFragment( + { nodes: clipboard.nodes, edges: clipboard.edges, groups: clipboard.groups }, + offset + ); + const newNodes: WorkflowNode[] = remapped.nodes.map((node) => ({ + ...node, + selected: true, // Select newly pasted nodes })); + const newEdges = remapped.edges; + const newGroups = remapped.groups; // Deselect existing nodes and add new ones const updatedNodes = nodes.map((node) => ({ @@ -3492,6 +3547,100 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ URL.revokeObjectURL(url); }, + appendWorkflow: (workflow: WorkflowFile) => { + if (!workflow.nodes || workflow.nodes.length === 0) return; + + pushUndoCheckpoint(get, set); + + // Normalize imported data (mirrors the relevant parts of loadWorkflow) without + // mutating the input. Migrate legacy node data and force image-generation nodes + // onto a Popi model. + let importedNodes = workflow.nodes.map((node) => { + const migrated = { ...node, data: migrateWorkflowNodeData(node.data) } as WorkflowNode; + if (migrated.type === "nanoBanana") { + const data = migrated.data as NanoBananaNodeData; + if (!data.selectedModel || data.selectedModel.provider !== "popiserver") { + return { + ...migrated, + data: { ...data, selectedModel: createDefaultImageModel() }, + } as WorkflowNode; + } + } + return migrated; + }); + + // Normalize / dedupe / filter edges within the imported fragment. + let importedEdges = workflow.edges.map((edge) => normalizeWorkflowEdgeHandles(edge)); + const importedEdgeById = new Map(); + for (const edge of importedEdges) importedEdgeById.set(edge.id, edge); + if (importedEdgeById.size < importedEdges.length) { + importedEdges = Array.from(importedEdgeById.values()); + } + const importedNodeIds = new Set(importedNodes.map((n) => n.id)); + importedEdges = importedEdges.filter( + (edge) => importedNodeIds.has(edge.source) && importedNodeIds.has(edge.target) + ); + + // Clear transient running state and rebuild native group shell nodes so the + // fragment is well-formed before id remapping. + importedNodes = resetTransientExecutionState(importedNodes).nodes; + const normalizedGroups = normalizeNativeGroupNodes(importedNodes, workflow.groups); + importedNodes = normalizedGroups.nodes; + + // Offset the imported fragment into empty space to the right of existing content. + const { nodes: currentNodes } = get(); + const GAP = 120; + let offset: XYPosition = { x: 0, y: 0 }; + const existingTopLevel = currentNodes.filter((n) => !n.parentId); + const importedTopLevel = importedNodes.filter((n) => !n.parentId); + if (existingTopLevel.length > 0 && importedTopLevel.length > 0) { + const existingRight = Math.max( + ...existingTopLevel.map((n) => n.position.x + getNodeSize(n).width) + ); + const existingTop = Math.min(...existingTopLevel.map((n) => n.position.y)); + const importedMinX = Math.min(...importedTopLevel.map((n) => n.position.x)); + const importedMinY = Math.min(...importedTopLevel.map((n) => n.position.y)); + offset = { + x: existingRight + GAP - importedMinX, + y: existingTop - importedMinY, + }; + } + + const remapped = remapWorkflowFragment( + { nodes: importedNodes, edges: importedEdges, groups: normalizedGroups.groups }, + offset + ); + const appendedNodes: WorkflowNode[] = remapped.nodes.map((node) => ({ + ...node, + selected: true, + })); + + const { edges, groups } = get(); + const deselectedExisting = currentNodes.map((node) => ({ ...node, selected: false })); + + // Keep current workflow identity (id/name/lastSavedAt/cost); only append content. + set({ + nodes: [...deselectedExisting, ...appendedNodes] as WorkflowNode[], + edges: [...edges, ...remapped.edges], + groups: { ...groups, ...remapped.groups }, + hasUnsavedChanges: true, + }); + + // Correct React Flow selection after reconciliation (same race as pasteNodes). + const appendedIdSet = new Set(appendedNodes.map((n) => n.id)); + requestAnimationFrame(() => { + const nextNodes = get().nodes; + const selectionChanges: NodeChange[] = nextNodes.map((n) => ({ + type: "select" as const, + id: n.id, + selected: appendedIdSet.has(n.id), + })); + get().onNodesChange(selectionChanges); + }); + + get().recomputeDimmedNodes(); + }, + loadWorkflow: async (workflow: WorkflowFile, workflowPath?: string, options?: { preserveSnapshot?: boolean }) => { // Update nodeIdCounter to avoid ID collisions const maxNodeId = workflow.nodes.reduce((max, node) => {