Browse Source

画布组内新建节点修改

feature/composer
TianYun 4 weeks ago
parent
commit
c8cd6df579
  1. 4
      src/app/globals.css
  2. 78
      src/components/WorkflowCanvas.tsx
  3. 37
      src/store/__tests__/workflowStore.integration.test.ts
  4. 50
      src/store/workflowStore.ts

4
src/app/globals.css

@ -8,7 +8,7 @@
color-scheme: dark; color-scheme: dark;
--canvas-bg: #101112; --canvas-bg: #101112;
--canvas-background: var(--canvas-bg); --canvas-background: var(--canvas-bg);
--canvas-grid: #ffffff; --canvas-grid: #454545;
--surface-1: #171819; --surface-1: #171819;
--surface-2: #202122; --surface-2: #202122;
--surface-3: #2a2b2d; --surface-3: #2a2b2d;
@ -62,7 +62,7 @@
color-scheme: light; color-scheme: light;
--canvas-bg: #f0f0f0; --canvas-bg: #f0f0f0;
--canvas-background: linear-gradient(132deg, #E9DAEE 3.04%, #E9E9E9 50%, #DAD6E5 96.96%); --canvas-background: linear-gradient(132deg, #E9DAEE 3.04%, #E9E9E9 50%, #DAD6E5 96.96%);
--canvas-grid: #EBEBEB; --canvas-grid: #CBC4DD;
--surface-1: #ffffff; --surface-1: #ffffff;
--surface-2: #ffffff; --surface-2: #ffffff;
--surface-3: #f3f3f2; --surface-3: #f3f3f2;

78
src/components/WorkflowCanvas.tsx

@ -158,6 +158,11 @@ interface ConnectionDropState {
sourceNodeType?: NodeType; sourceNodeType?: NodeType;
sourceNodeId: string | null; sourceNodeId: string | null;
sourceHandleId: string | null; sourceHandleId: string | null;
sourceGroupId?: string;
}
function getNodeGroupId(node: Node): string | undefined {
return (node as Node & { groupId?: string }).groupId;
} }
function uniqueConnectionMediaTypes(mediaTypes: ConcreteHandleType[]): ConcreteHandleType[] { function uniqueConnectionMediaTypes(mediaTypes: ConcreteHandleType[]): ConcreteHandleType[] {
@ -248,6 +253,7 @@ interface MultiAngleDialogState {
sourceNodeId: string; sourceNodeId: string;
sourceHandleId: string; sourceHandleId: string;
flowPosition: { x: number; y: number }; flowPosition: { x: number; y: number };
sourceGroupId?: string;
settings: MultiAngleSettings; settings: MultiAngleSettings;
} }
@ -424,6 +430,7 @@ export function WorkflowCanvas() {
const onEdgesChange = useWorkflowStore((state) => state.onEdgesChange); const onEdgesChange = useWorkflowStore((state) => state.onEdgesChange);
const onConnect = useWorkflowStore((state) => state.onConnect); const onConnect = useWorkflowStore((state) => state.onConnect);
const addNode = useWorkflowStore((state) => state.addNode); const addNode = useWorkflowStore((state) => state.addNode);
const addNodeToGroup = useWorkflowStore((state) => state.addNodeToGroup);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow); const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow);
const getNodeById = useWorkflowStore((state) => state.getNodeById); const getNodeById = useWorkflowStore((state) => state.getNodeById);
@ -461,6 +468,17 @@ export function WorkflowCanvas() {
const reactFlowWrapper = useRef<HTMLDivElement>(null); const reactFlowWrapper = useRef<HTMLDivElement>(null);
const tutorialViewportSet = useRef(false); const tutorialViewportSet = useRef(false);
const addNodeWithOptionalGroup = useCallback((
type: NodeType,
position: { x: number; y: number },
sourceGroupId: string | undefined,
initialData?: Parameters<typeof addNode>[2]
) => (
sourceGroupId
? addNodeToGroup(type, position, sourceGroupId, initialData)
: addNode(type, position, initialData)
), [addNode, addNodeToGroup]);
const syncZoomPercent = useCallback(() => { const syncZoomPercent = useCallback(() => {
setZoomPercent(Math.round(getViewport().zoom * 100)); setZoomPercent(Math.round(getViewport().zoom * 100));
}, [getViewport]); }, [getViewport]);
@ -849,12 +867,13 @@ export function WorkflowCanvas() {
if (!source) return; if (!source) return;
const { width } = getNodeDimensions(node); const { width } = getNodeDimensions(node);
const absolutePosition = absoluteNodePositions.get(node.id) ?? node.position;
setCanvasNodeMenu(null); setCanvasNodeMenu(null);
setConnectionDrop({ setConnectionDrop({
position: clientPosition, position: clientPosition,
flowPosition: { flowPosition: {
x: node.position.x + width + 160, x: absolutePosition.x + width + 160,
y: node.position.y, y: absolutePosition.y,
}, },
intent: { intent: {
direction: "fromOutput", direction: "fromOutput",
@ -869,6 +888,7 @@ export function WorkflowCanvas() {
sourceNodeType: node.type as NodeType, sourceNodeType: node.type as NodeType,
sourceNodeId: node.id, sourceNodeId: node.id,
sourceHandleId: source.handleId, sourceHandleId: source.handleId,
sourceGroupId: getNodeGroupId(node),
}); });
if (tutorialActive) { if (tutorialActive) {
@ -876,7 +896,7 @@ export function WorkflowCanvas() {
} }
return true; return true;
}, [edges, tutorialActive]); }, [absoluteNodePositions, edges, tutorialActive]);
const openConnectionMenuFromHandle = useCallback((request: NodeHandleMenuRequest) => { const openConnectionMenuFromHandle = useCallback((request: NodeHandleMenuRequest) => {
if (isModalOpen) return; if (isModalOpen) return;
@ -896,11 +916,12 @@ export function WorkflowCanvas() {
if (!handleType) return; if (!handleType) return;
const { width } = getNodeDimensions(node); const { width } = getNodeDimensions(node);
const absolutePosition = absoluteNodePositions.get(node.id) ?? node.position;
setConnectionDrop({ setConnectionDrop({
position: request.clientPosition, position: request.clientPosition,
flowPosition: { flowPosition: {
x: node.position.x + width + 160, x: absolutePosition.x + width + 160,
y: node.position.y, y: absolutePosition.y,
}, },
intent: { intent: {
...intent, ...intent,
@ -913,15 +934,17 @@ export function WorkflowCanvas() {
sourceNodeType: node.type as NodeType, sourceNodeType: node.type as NodeType,
sourceNodeId: node.id, sourceNodeId: node.id,
sourceHandleId: request.handleId || source?.handleId || SINGLE_OUTPUT_HANDLE_ID, sourceHandleId: request.handleId || source?.handleId || SINGLE_OUTPUT_HANDLE_ID,
sourceGroupId: getNodeGroupId(node),
}); });
} else { } else {
const { width } = getNodeDimensions(node); const { width } = getNodeDimensions(node);
const handleType = intent.mediaTypes[0] ?? null; const handleType = intent.mediaTypes[0] ?? null;
const absolutePosition = absoluteNodePositions.get(node.id) ?? node.position;
setConnectionDrop({ setConnectionDrop({
position: request.clientPosition, position: request.clientPosition,
flowPosition: { flowPosition: {
x: node.position.x - width - 120, x: absolutePosition.x - width - 120,
y: node.position.y, y: absolutePosition.y,
}, },
intent, intent,
handleType, handleType,
@ -929,13 +952,14 @@ export function WorkflowCanvas() {
mode: "drag", mode: "drag",
sourceNodeId: node.id, sourceNodeId: node.id,
sourceHandleId: request.handleId || SINGLE_INPUT_HANDLE_ID, sourceHandleId: request.handleId || SINGLE_INPUT_HANDLE_ID,
sourceGroupId: getNodeGroupId(node),
}); });
} }
if (tutorialActive) { if (tutorialActive) {
useFTUXStore.getState().setConnectionMenuShown(true); useFTUXStore.getState().setConnectionMenuShown(true);
} }
}, [edges, isModalOpen, nodes, tutorialActive]); }, [absoluteNodePositions, edges, isModalOpen, nodes, tutorialActive]);
const nodeHandleMenuValue = useMemo(() => ({ const nodeHandleMenuValue = useMemo(() => ({
openConnectionMenuFromHandle, openConnectionMenuFromHandle,
@ -1053,7 +1077,7 @@ export function WorkflowCanvas() {
// Get current groupId of the node // Get current groupId of the node
const currentNode = nodes.find((n) => n.id === node.id); const currentNode = nodes.find((n) => n.id === node.id);
const currentGroupId = currentNode?.groupId; const currentGroupId = currentNode ? getNodeGroupId(currentNode) : undefined;
// Update groupId if it changed // Update groupId if it changed
if (targetGroupId !== currentGroupId) { if (targetGroupId !== currentGroupId) {
@ -1316,6 +1340,7 @@ export function WorkflowCanvas() {
mode: "drag", mode: "drag",
sourceNodeId: connectionState.fromNode.id, sourceNodeId: connectionState.fromNode.id,
sourceHandleId: fromHandleId, sourceHandleId: fromHandleId,
sourceGroupId: getNodeGroupId(connectionState.fromNode),
}); });
// Tutorial tracking // Tutorial tracking
@ -1328,7 +1353,7 @@ export function WorkflowCanvas() {
// Handle the splitGrid action - uses automated grid detection // Handle the splitGrid action - uses automated grid detection
const handleSplitGridAction = useCallback( const handleSplitGridAction = useCallback(
async (sourceNodeId: string, flowPosition: { x: number; y: number }) => { async (sourceNodeId: string, flowPosition: { x: number; y: number }, sourceGroupId?: string) => {
const sourceNode = getNodeById(sourceNodeId); const sourceNode = getNodeById(sourceNodeId);
if (!sourceNode) return; if (!sourceNode) return;
@ -1373,10 +1398,14 @@ export function WorkflowCanvas() {
const row = Math.floor(index / grid.cols); const row = Math.floor(index / grid.cols);
const col = index % grid.cols; const col = index % grid.cols;
const nodeId = addNode("imageInput", { const nodeId = addNodeWithOptionalGroup(
x: flowPosition.x + col * (nodeWidth + gap), "imageInput",
y: flowPosition.y + row * (nodeHeight + gap), {
}); x: flowPosition.x + col * (nodeWidth + gap),
y: flowPosition.y + row * (nodeHeight + gap),
},
sourceGroupId
);
// Get dimensions from the split image // Get dimensions from the split image
const img = new Image(); const img = new Image();
@ -1397,7 +1426,7 @@ export function WorkflowCanvas() {
setIsSplitting(false); setIsSplitting(false);
} }
}, },
[getNodeById, edges, addNode, updateNodeData, t] [getNodeById, edges, addNodeWithOptionalGroup, updateNodeData, t]
); );
// Helper to get image from a node // Helper to get image from a node
@ -1520,12 +1549,12 @@ export function WorkflowCanvas() {
async (selection: { type: NodeType | MenuAction; isAction: boolean }) => { async (selection: { type: NodeType | MenuAction; isAction: boolean }) => {
if (!connectionDrop) return; if (!connectionDrop) return;
const { flowPosition, sourceNodeId, sourceHandleId, connectionType, handleType } = connectionDrop; const { flowPosition, sourceNodeId, sourceHandleId, sourceGroupId, connectionType, handleType } = connectionDrop;
// Handle actions differently from node creation // Handle actions differently from node creation
if (selection.isAction) { if (selection.isAction) {
if (selection.type === "splitGridImmediate" && sourceNodeId) { if (selection.type === "splitGridImmediate" && sourceNodeId) {
handleSplitGridAction(sourceNodeId, flowPosition); handleSplitGridAction(sourceNodeId, flowPosition, sourceGroupId);
} else if ( } else if (
selection.type === "multiAngleImage" && selection.type === "multiAngleImage" &&
sourceNodeId && sourceNodeId &&
@ -1537,6 +1566,7 @@ export function WorkflowCanvas() {
sourceNodeId, sourceNodeId,
sourceHandleId, sourceHandleId,
flowPosition, flowPosition,
sourceGroupId,
settings: { ...DEFAULT_MULTI_ANGLE_SETTINGS }, settings: { ...DEFAULT_MULTI_ANGLE_SETTINGS },
}); });
} }
@ -1548,7 +1578,12 @@ export function WorkflowCanvas() {
const nodeType = selection.type as NodeType; const nodeType = selection.type as NodeType;
// Create the new node at the drop position (empty - tutorial will populate after connection) // Create the new node at the drop position (empty - tutorial will populate after connection)
const newNodeId = addNode(nodeType, flowPosition, await buildDefaultGenerationNodeData(nodeType)); const newNodeId = addNodeWithOptionalGroup(
nodeType,
flowPosition,
sourceGroupId,
await buildDefaultGenerationNodeData(nodeType)
);
// Tutorial tracking // Tutorial tracking
if (tutorialActive && nodeType === "nanoBanana") { if (tutorialActive && nodeType === "nanoBanana") {
@ -1787,7 +1822,7 @@ export function WorkflowCanvas() {
selectSingleNode(newNodeId); selectSingleNode(newNodeId);
setConnectionDrop(null); setConnectionDrop(null);
}, },
[connectionDrop, addNode, onConnect, nodes, edges, handleSplitGridAction, getImageFromNode, updateNodeData, tutorialActive, selectSingleNode] [connectionDrop, addNodeWithOptionalGroup, onConnect, nodes, edges, handleSplitGridAction, getImageFromNode, updateNodeData, tutorialActive, selectSingleNode]
); );
const handleCloseDropMenu = useCallback(() => { const handleCloseDropMenu = useCallback(() => {
@ -1859,9 +1894,10 @@ export function WorkflowCanvas() {
return; return;
} }
const newNodeId = addNode( const newNodeId = addNodeWithOptionalGroup(
"nanoBanana", "nanoBanana",
multiAngleDialog.flowPosition, multiAngleDialog.flowPosition,
multiAngleDialog.sourceGroupId,
buildMultiAngleNodeData(sourceImage, multiAngleDialog.settings) buildMultiAngleNodeData(sourceImage, multiAngleDialog.settings)
); );
@ -1874,7 +1910,7 @@ export function WorkflowCanvas() {
selectSingleNode(newNodeId); selectSingleNode(newNodeId);
setMultiAngleDialog(null); setMultiAngleDialog(null);
await regenerateNode(newNodeId); await regenerateNode(newNodeId);
}, [addNode, getImageFromNode, multiAngleDialog, onConnect, regenerateNode, selectSingleNode, showToast, t]); }, [addNodeWithOptionalGroup, getImageFromNode, multiAngleDialog, onConnect, regenerateNode, selectSingleNode, showToast, t]);
// Get copy/paste functions and clipboard from store // Get copy/paste functions and clipboard from store
const copySelectedNodes = useWorkflowStore((state) => state.copySelectedNodes); const copySelectedNodes = useWorkflowStore((state) => state.copySelectedNodes);

37
src/store/__tests__/workflowStore.integration.test.ts

@ -3042,6 +3042,43 @@ describe("workflowStore integration tests", () => {
}); });
describe("addNodeToGroup", () => {
it("creates a grouped child using absolute canvas coordinates", () => {
useWorkflowStore.setState({
nodes: [
{
id: "group-node-group-1",
type: "group",
position: { x: 400, y: 300 },
data: { groupId: "group-1" },
style: { width: 800, height: 600 },
} as WorkflowNode,
createTestNode("prompt-1", "prompt", { text: "source" }, { x: 40, y: 50 }),
],
edges: [],
groups: {
"group-1": {
id: "group-1",
name: "Test Group",
color: "neutral" as const,
position: { x: 400, y: 300 },
size: { width: 800, height: 600 },
locked: false,
},
},
});
const nodeId = useWorkflowStore.getState().addNodeToGroup("imageInput", { x: 760, y: 380 }, "group-1");
const node = useWorkflowStore.getState().nodes.find((item) => item.id === nodeId);
expect(node?.groupId).toBe("group-1");
expect(node?.parentId).toBe("group-node-group-1");
expect(node?.position).toEqual({ x: 360, y: 80 });
expect(node?.extent).toBeUndefined();
expect(node?.expandParent).toBeUndefined();
});
});
describe("setNodeGroupId with non-standard node types", () => { describe("setNodeGroupId with non-standard node types", () => {
it("should assign and remove groupId for easeCurve, videoStitch, and audioInput nodes", () => { it("should assign and remove groupId for easeCurve, videoStitch, and audioInput nodes", () => {
const group = { const group = {

50
src/store/workflowStore.ts

@ -251,6 +251,7 @@ interface WorkflowStore {
// Node operations // Node operations
addNode: (type: NodeType, position: XYPosition, initialData?: Partial<WorkflowNodeData>) => string; addNode: (type: NodeType, position: XYPosition, initialData?: Partial<WorkflowNodeData>) => string;
addNodeToGroup: (type: NodeType, position: XYPosition, groupId: string, initialData?: Partial<WorkflowNodeData>) => string;
convertNodeType: (nodeId: string, type: NodeType, initialData?: Partial<WorkflowNodeData>) => void; convertNodeType: (nodeId: string, type: NodeType, initialData?: Partial<WorkflowNodeData>) => void;
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => void; updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => void;
removeNode: (nodeId: string) => void; removeNode: (nodeId: string) => void;
@ -1043,6 +1044,55 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
return id; return id;
}, },
addNodeToGroup: (type: NodeType, position: XYPosition, groupId: string, initialData?: Partial<WorkflowNodeData>) => {
const group = get().groups[groupId];
if (!group) return get().addNode(type, position, initialData);
const id = `${type}-${++nodeIdCounter}`;
const { width, height } = defaultNodeDimensions[type];
const state = get();
const nodeById = new Map(state.nodes.map((node) => [node.id, node]));
const collisionNodes = state.nodes
.filter((node) => node.type !== "group")
.map((node) => ({
...node,
position: getAbsoluteNodePosition(node, nodeById),
}));
const finalPosition = findNearestFreePosition(position, type, collisionNodes);
const defaultData = createDefaultNodeData(type);
const nodeData = initialData
? ({ ...defaultData, ...initialData } as WorkflowNodeData)
: defaultData;
const groupNodeId = getGroupNodeId(groupId);
const newNode: WorkflowNode = {
id,
type,
parentId: groupNodeId,
groupId,
position: {
x: finalPosition.x - group.position.x,
y: finalPosition.y - group.position.y,
},
data: nodeData,
style: { width, height },
};
pushUndoCheckpoint(get, set);
set((state) => ({
nodes: [
...(state.nodes.some((node) => node.id === groupNodeId) ? [] : [createGroupNode(group)]),
...state.nodes,
newNode,
],
hasUnsavedChanges: true,
}));
get().incrementManualChangeCount();
return id;
},
convertNodeType: (nodeId: string, type: NodeType, initialData?: Partial<WorkflowNodeData>) => { convertNodeType: (nodeId: string, type: NodeType, initialData?: Partial<WorkflowNodeData>) => {
const sourceNode = get().nodes.find((node) => node.id === nodeId); const sourceNode = get().nodes.find((node) => node.id === nodeId);
if (!sourceNode) return; if (!sourceNode) return;

Loading…
Cancel
Save