From db71b2485a9c7bf255ebe16d06ca13bfc3cb6673 Mon Sep 17 00:00:00 2001
From: Matthias Meister
Date: Sat, 11 Apr 2026 08:56:45 +0200
Subject: [PATCH] refactor(canvas): unify node handles with shared wrapper
---
.../canvas/__tests__/compare-node.test.tsx | 56 +++++++++++++++++++
.../canvas/__tests__/mixer-node.test.tsx | 43 +++++++++++++-
components/canvas/nodes/agent-node.tsx | 11 +++-
components/canvas/nodes/agent-output-node.tsx | 9 ++-
components/canvas/nodes/ai-image-node.tsx | 11 +++-
components/canvas/nodes/ai-video-node.tsx | 11 +++-
components/canvas/nodes/asset-node.tsx | 11 +++-
components/canvas/nodes/color-adjust-node.tsx | 11 +++-
components/canvas/nodes/compare-node.tsx | 15 +++--
components/canvas/nodes/crop-node.tsx | 11 +++-
components/canvas/nodes/curves-node.tsx | 11 +++-
.../canvas/nodes/detail-adjust-node.tsx | 11 +++-
components/canvas/nodes/frame-node.tsx | 11 +++-
components/canvas/nodes/group-node.tsx | 11 +++-
components/canvas/nodes/image-node.tsx | 11 +++-
components/canvas/nodes/light-adjust-node.tsx | 11 +++-
components/canvas/nodes/mixer-node.tsx | 15 +++--
components/canvas/nodes/note-node.tsx | 11 +++-
components/canvas/nodes/prompt-node.tsx | 10 +++-
components/canvas/nodes/render-node.tsx | 11 +++-
components/canvas/nodes/text-node.tsx | 10 +++-
components/canvas/nodes/video-node.tsx | 11 +++-
components/canvas/nodes/video-prompt-node.tsx | 11 +++-
23 files changed, 266 insertions(+), 68 deletions(-)
diff --git a/components/canvas/__tests__/compare-node.test.tsx b/components/canvas/__tests__/compare-node.test.tsx
index 9e9f14b..aa60d8c 100644
--- a/components/canvas/__tests__/compare-node.test.tsx
+++ b/components/canvas/__tests__/compare-node.test.tsx
@@ -28,6 +28,31 @@ vi.mock("@xyflow/react", () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
+vi.mock("@/components/canvas/canvas-handle", () => ({
+ default: ({
+ id,
+ type,
+ nodeId,
+ nodeType,
+ style,
+ }: {
+ id?: string;
+ type: "source" | "target";
+ nodeId: string;
+ nodeType?: string;
+ style?: React.CSSProperties;
+ }) => (
+
+ ),
+}));
+
vi.mock("../nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => {children}
,
}));
@@ -261,4 +286,35 @@ describe("CompareNode render preview inputs", () => {
},
});
});
+
+ it("renders compare handles through CanvasHandle with preserved ids and positions", () => {
+ const markup = renderCompareNode({
+ id: "compare-1",
+ data: {},
+ selected: false,
+ dragging: false,
+ zIndex: 0,
+ isConnectable: true,
+ type: "compare",
+ xPos: 0,
+ yPos: 0,
+ width: 500,
+ height: 380,
+ sourcePosition: undefined,
+ targetPosition: undefined,
+ positionAbsoluteX: 0,
+ positionAbsoluteY: 0,
+ });
+
+ expect(markup).toContain('data-canvas-handle="true"');
+ expect(markup).toContain('data-node-id="compare-1"');
+ expect(markup).toContain('data-node-type="compare"');
+ expect(markup).toContain('data-handle-id="left"');
+ expect(markup).toContain('data-handle-id="right"');
+ expect(markup).toContain('data-handle-id="compare-out"');
+ expect(markup).toContain('data-handle-type="target"');
+ expect(markup).toContain('data-handle-type="source"');
+ expect(markup).toContain('data-top="35%"');
+ expect(markup).toContain('data-top="55%"');
+ });
});
diff --git a/components/canvas/__tests__/mixer-node.test.tsx b/components/canvas/__tests__/mixer-node.test.tsx
index d4c8e75..f0152d1 100644
--- a/components/canvas/__tests__/mixer-node.test.tsx
+++ b/components/canvas/__tests__/mixer-node.test.tsx
@@ -17,6 +17,31 @@ vi.mock("@xyflow/react", () => ({
Position: { Left: "left", Right: "right" },
}));
+vi.mock("@/components/canvas/canvas-handle", () => ({
+ default: ({
+ id,
+ type,
+ nodeId,
+ nodeType,
+ style,
+ }: {
+ id?: string;
+ type: "source" | "target";
+ nodeId: string;
+ nodeType?: string;
+ style?: React.CSSProperties;
+ }) => (
+
+ ),
+}));
+
vi.mock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
@@ -222,8 +247,20 @@ describe("MixerNode", () => {
it("renders expected mixer handles", async () => {
await renderNode();
- expect(container?.querySelector('[data-handle-id="base"][data-handle-type="target"]')).toBeTruthy();
- expect(container?.querySelector('[data-handle-id="overlay"][data-handle-type="target"]')).toBeTruthy();
- expect(container?.querySelector('[data-handle-id="mixer-out"][data-handle-type="source"]')).toBeTruthy();
+ expect(
+ container?.querySelector(
+ '[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="base"][data-handle-type="target"][data-top="35%"]',
+ ),
+ ).toBeTruthy();
+ expect(
+ container?.querySelector(
+ '[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="overlay"][data-handle-type="target"][data-top="58%"]',
+ ),
+ ).toBeTruthy();
+ expect(
+ container?.querySelector(
+ '[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="mixer-out"][data-handle-type="source"]',
+ ),
+ ).toBeTruthy();
});
});
diff --git a/components/canvas/nodes/agent-node.tsx b/components/canvas/nodes/agent-node.tsx
index ed0228f..bfe8e41 100644
--- a/components/canvas/nodes/agent-node.tsx
+++ b/components/canvas/nodes/agent-node.tsx
@@ -2,7 +2,7 @@
import { useCallback, useMemo, useState } from "react";
import { Bot } from "lucide-react";
-import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
+import { Position, type Node, type NodeProps } from "@xyflow/react";
import { useAction } from "convex/react";
import type { FunctionReference } from "convex/server";
import { useTranslations } from "next-intl";
@@ -33,6 +33,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import BaseNodeWrapper from "./base-node-wrapper";
+import CanvasHandle from "@/components/canvas/canvas-handle";
type AgentNodeData = {
templateId?: string;
@@ -466,13 +467,17 @@ export default function AgentNode({ id, data, selected }: NodeProps
-
- ) {
+export default function AgentOutputNode({ id, data, selected }: NodeProps) {
const t = useTranslations("agentOutputNode");
const nodeData = data as AgentOutputNodeData;
const isSkeleton = nodeData.isSkeleton === true;
@@ -240,7 +241,9 @@ export default function AgentOutputNode({ data, selected }: NodeProps
-
-
)}
-
-
-
-
) : null}
-
-
-
-
-
-
- {error}
: null}
-
-
-
-
-
-
- )
selected={selected}
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
>
- )
)}
-
-
-
-
-
-
-
- ) {
return (
- ) {
)}
-
-
-
-
- ) {
]}
className="relative"
>
- ) {
)}
-
-
) : null}
-
-
-