Browse Source

画布追加

feature/video-erase
Luckyu_js 1 week ago
parent
commit
096b0ad8be
  1. 17
      src/components/WorkflowCanvas.tsx
  2. 35
      src/components/media/image-edit/AnnotationEditSession.tsx
  3. 74
      src/components/media/image-edit/useImageDerivedNodeActions.ts
  4. 158
      src/store/__tests__/workflowStore.integration.test.ts
  5. 102
      src/store/utils/__tests__/remapWorkflowIds.test.ts
  6. 82
      src/store/utils/remapWorkflowIds.ts
  7. 299
      src/store/workflowStore.ts

17
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<HTMLDivElement>) => {
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) => {

35
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(() => {

74
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<SmartImageNodeData>, 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,

158
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);
});
});
});

102
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<string, string>([
["imageInput-1", "imageInput-10"],
["video-1", "video-20"],
["video-2", "video-21"],
]);
const edgeMap = new Map<string, string>([
["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();
});
});

82
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<T>(
type: NodeType,
data: T,
nodeIdMapping: Map<string, string>,
edgeIdMapping: Map<string, string>
): 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<string, unknown>;
const next: Record<string, unknown> = { ...source };
// referenceSubjectList[].id === source node id
if (Array.isArray(source.referenceSubjectList)) {
next.referenceSubjectList = (source.referenceSubjectList as Array<Record<string, unknown>>).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<string, unknown>;
next[key] = { ...d, sourceNodeId: mapNode(d.sourceNodeId) };
}
}
// videoStitch clips: sourceNodeId (node) + edgeId (edge)
if (Array.isArray(source.clips)) {
next.clips = (source.clips as Array<Record<string, unknown>>).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;
}

299
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<void>;
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<string, NodeGroup>;
},
offset: XYPosition
): {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
groups: Record<string, NodeGroup>;
idMapping: Map<string, string>;
} {
// Give every group a fresh identity so the copy is fully independent.
const sourceGroups = fragment.groups ?? {};
const groupIdMapping = new Map<string, string>();
const newGroups: Record<string, NodeGroup> = {};
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-<groupId>` id derived from their new group id.
const idMapping = new Map<string, string>();
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<string, string>();
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<string, RunningTaskEntry>): Set<string> {
return new Set(Object.values(tasks).map((task) => task.nodeId));
}
@ -2329,82 +2449,17 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (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<string, string>();
const newGroups: Record<string, NodeGroup> = {};
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-<groupId>` id derived from their new group id.
const idMapping = new Map<string, string>();
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<WorkflowStore> = (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<string, WorkflowEdge>();
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<WorkflowNode>[] = 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) => {

Loading…
Cancel
Save