Merge branch 'feat/canvas-magnetism-20260411-082412'

This commit is contained in:
2026-04-11 10:47:03 +02:00
50 changed files with 2589 additions and 354 deletions

View File

@@ -190,6 +190,11 @@
z-index: 50; z-index: 50;
} }
/* Reconnect-Anker immer pointer-interactive halten (Drag-Detach/Reconnect) */
.react-flow__edgeupdater {
pointer-events: all;
}
/* Proximity-Vorschaukante (temp) */ /* Proximity-Vorschaukante (temp) */
.react-flow__edge.temp { .react-flow__edge.temp {
opacity: 0.9; opacity: 0.9;

View File

@@ -35,10 +35,21 @@ app/(app)/canvas/[canvasId]/page.tsx
| `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) | | `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) |
| `canvas-delete-handlers.ts` | Hook für `onBeforeDelete`, `onNodesDelete`, `onEdgesDelete` inkl. Bridge-Edges | | `canvas-delete-handlers.ts` | Hook für `onBeforeDelete`, `onNodesDelete`, `onEdgesDelete` inkl. Bridge-Edges |
| `canvas-reconnect.ts` | Hook für Edge-Reconnect (`onReconnectStart`, `onReconnect`, `onReconnectEnd`) | | `canvas-reconnect.ts` | Hook für Edge-Reconnect (`onReconnectStart`, `onReconnect`, `onReconnectEnd`) |
| `canvas-connection-magnetism.ts` | Pure Magnet-Resolver für Handle-Proximity (`resolveCanvasMagnetTarget`) inkl. Glow/Snap-Radien |
| `canvas-connection-magnetism-context.tsx` | Transienter Client-State für aktives Magnet-Target während Connect/Reconnect-Drags |
| `canvas-media-utils.ts` | Media-Helfer wie `getImageDimensions(file)` | | `canvas-media-utils.ts` | Media-Helfer wie `getImageDimensions(file)` |
| `use-canvas-data.ts` | Hook: Bündelt Canvas-Graph-Query, Storage-URL-Auflösung und Auth-State in einer einzigen Abstraktion | | `use-canvas-data.ts` | Hook: Bündelt Canvas-Graph-Query, Storage-URL-Auflösung und Auth-State in einer einzigen Abstraktion |
| `canvas-graph-query-cache.ts` | Optimistic Store Helper für `canvasGraph.get` (getNodes, getEdges, setNodes, setEdges) | | `canvas-graph-query-cache.ts` | Optimistic Store Helper für `canvasGraph.get` (getNodes, getEdges, setNodes, setEdges) |
### Connection Magnetism (client-only)
- Magnetism ist eine rein clientseitige UX-Schicht über dem bestehenden React-Flow-Connect-Flow; Persistenz, Edge-Schema und Convex-Mutations bleiben unverändert.
- `HANDLE_GLOW_RADIUS_PX = 56` und `HANDLE_SNAP_RADIUS_PX = 40` liegen zentral in `canvas-connection-magnetism.ts` und werden von Resolver, Handle-Glow und Connection-Line gemeinsam genutzt.
- `resolveCanvasMagnetTarget(...)` sucht LemonSpace-eigene Handle-DOM-Kandidaten über `data-node-id` / `data-handle-id` / `data-handle-type`, berechnet die Distanz zum Pointer und wählt stabil das nächste gültige Handle.
- `CanvasConnectionMagnetismProvider` (in `canvas.tsx`) stellt `activeTarget` und `setActiveTarget` für `CustomConnectionLine`, `CanvasHandle` und Connect/Reconnect-Hooks bereit; der State ist transient und wird nach Drag-Ende geleert.
- `CanvasHandle` ist der gemeinsame Wrapper für alle Node-Handles (statt direktes `<Handle>` pro Node), rendert `idle|near|snapped` Glow-States und exportiert stabile `data-*` Attribute für die Geometrie-Lookups.
- Connectability bleibt strikt policy-getrieben: Magnet-Targets werden nur akzeptiert, wenn `validateCanvasConnectionPolicy(...)` bzw. die bestehende Validierungslogik die Verbindung erlaubt.
--- ---
## Node-Taxonomie (Phase 1) ## Node-Taxonomie (Phase 1)

View File

@@ -7,7 +7,6 @@ import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpe
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode { function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
return { return {
id: overrides.id,
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: {}, data: {},
...overrides, ...overrides,
@@ -40,6 +39,34 @@ function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
return element; return element;
} }
function makeHandleElement(args: {
nodeId: string;
handleType: "source" | "target";
handleId?: string;
rect: Partial<DOMRect>;
}): HTMLElement {
const element = document.createElement("div");
element.className = "react-flow__handle";
element.dataset.nodeId = args.nodeId;
element.dataset.handleType = args.handleType;
if (args.handleId !== undefined) {
element.dataset.handleId = args.handleId;
}
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
x: args.rect.left ?? 0,
y: args.rect.top ?? 0,
top: args.rect.top ?? 0,
left: args.rect.left ?? 0,
right: args.rect.right ?? 10,
bottom: args.rect.bottom ?? 10,
width: args.rect.width ?? 10,
height: args.rect.height ?? 10,
toJSON: () => ({}),
} as DOMRect);
document.body.appendChild(element);
return element;
}
describe("resolveDroppedConnectionTarget", () => { describe("resolveDroppedConnectionTarget", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -144,6 +171,169 @@ describe("resolveDroppedConnectionTarget", () => {
}); });
}); });
it("resolves nearest valid target handle even without a node body hit", () => {
const sourceNode = createNode({
id: "node-source",
type: "image",
position: { x: 0, y: 0 },
});
const compareNode = createNode({
id: "node-compare",
type: "compare",
position: { x: 320, y: 200 },
});
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => []),
configurable: true,
});
makeHandleElement({
nodeId: "node-compare",
handleType: "target",
handleId: "left",
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
});
makeHandleElement({
nodeId: "node-compare",
handleType: "target",
handleId: "right",
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
});
const result = resolveDroppedConnectionTarget({
point: { x: 364, y: 258 },
fromNodeId: "node-source",
fromHandleType: "source",
nodes: [sourceNode, compareNode],
edges: [],
});
expect(result).toEqual({
sourceNodeId: "node-source",
targetNodeId: "node-compare",
sourceHandle: undefined,
targetHandle: "left",
});
});
it("skips a closer invalid handle and picks the nearest valid handle", () => {
const sourceNode = createNode({
id: "node-source",
type: "image",
position: { x: 0, y: 0 },
});
const mixerNode = createNode({
id: "node-mixer",
type: "mixer",
position: { x: 320, y: 200 },
});
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => []),
configurable: true,
});
makeHandleElement({
nodeId: "node-mixer",
handleType: "target",
handleId: "base",
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
});
makeHandleElement({
nodeId: "node-mixer",
handleType: "target",
handleId: "overlay",
rect: { left: 386, top: 278, width: 12, height: 12, right: 398, bottom: 290 },
});
const result = resolveDroppedConnectionTarget({
point: { x: 364, y: 258 },
fromNodeId: "node-source",
fromHandleType: "source",
nodes: [sourceNode, mixerNode],
edges: [
createEdge({
id: "edge-base-taken",
source: "node-source",
target: "node-mixer",
targetHandle: "base",
}),
],
});
expect(result).toEqual({
sourceNodeId: "node-source",
targetNodeId: "node-mixer",
sourceHandle: undefined,
targetHandle: "overlay",
});
});
it("prefers the actually nearest handle for compare and mixer targets", () => {
const sourceNode = createNode({
id: "node-source",
type: "image",
position: { x: 0, y: 0 },
});
const compareNode = createNode({
id: "node-compare",
type: "compare",
position: { x: 320, y: 200 },
});
const mixerNode = createNode({
id: "node-mixer",
type: "mixer",
position: { x: 640, y: 200 },
});
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => []),
configurable: true,
});
makeHandleElement({
nodeId: "node-compare",
handleType: "target",
handleId: "left",
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
});
makeHandleElement({
nodeId: "node-compare",
handleType: "target",
handleId: "right",
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
});
makeHandleElement({
nodeId: "node-mixer",
handleType: "target",
handleId: "base",
rect: { left: 678, top: 252, width: 12, height: 12, right: 690, bottom: 264 },
});
makeHandleElement({
nodeId: "node-mixer",
handleType: "target",
handleId: "overlay",
rect: { left: 678, top: 292, width: 12, height: 12, right: 690, bottom: 304 },
});
const compareResult = resolveDroppedConnectionTarget({
point: { x: 364, y: 258 },
fromNodeId: "node-source",
fromHandleType: "source",
nodes: [sourceNode, compareNode, mixerNode],
edges: [],
});
const mixerResult = resolveDroppedConnectionTarget({
point: { x: 684, y: 299 },
fromNodeId: "node-source",
fromHandleType: "source",
nodes: [sourceNode, compareNode, mixerNode],
edges: [],
});
expect(compareResult?.targetHandle).toBe("left");
expect(mixerResult?.targetHandle).toBe("overlay");
});
it("reverses the connection when the drag starts from a target handle", () => { it("reverses the connection when the drag starts from a target handle", () => {
const droppedNode = createNode({ const droppedNode = createNode({
id: "node-dropped", id: "node-dropped",
@@ -177,4 +367,44 @@ describe("resolveDroppedConnectionTarget", () => {
targetHandle: "target-handle", targetHandle: "target-handle",
}); });
}); });
it("resolves nearest source handle when drag starts from target handle", () => {
const fromNode = createNode({
id: "node-compare-target",
type: "compare",
position: { x: 0, y: 0 },
});
const compareNode = createNode({
id: "node-compare-source",
type: "compare",
position: { x: 320, y: 200 },
});
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => []),
configurable: true,
});
makeHandleElement({
nodeId: "node-compare-source",
handleType: "source",
handleId: "compare-out",
rect: { left: 478, top: 288, width: 12, height: 12, right: 490, bottom: 300 },
});
const result = resolveDroppedConnectionTarget({
point: { x: 484, y: 294 },
fromNodeId: "node-compare-target",
fromHandleId: "left",
fromHandleType: "target",
nodes: [fromNode, compareNode],
edges: [],
});
expect(result).toEqual({
sourceNodeId: "node-compare-source",
targetNodeId: "node-compare-target",
sourceHandle: "compare-out",
targetHandle: "left",
});
});
}); });

View File

@@ -0,0 +1,346 @@
// @vitest-environment jsdom
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
HANDLE_GLOW_RADIUS_PX,
HANDLE_SNAP_RADIUS_PX,
} from "@/components/canvas/canvas-connection-magnetism";
import {
CanvasConnectionMagnetismProvider,
useCanvasConnectionMagnetism,
} from "@/components/canvas/canvas-connection-magnetism-context";
const connectionStateRef: {
current: {
inProgress?: boolean;
fromNode?: { id: string };
fromHandle?: { id?: string; type?: "source" | "target" };
toNode?: { id: string } | null;
toHandle?: { id?: string | null; type?: "source" | "target" } | null;
isValid?: boolean | null;
};
} = {
current: { inProgress: false },
};
vi.mock("@xyflow/react", () => ({
Handle: ({
className,
style,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
className?: string;
style?: React.CSSProperties;
}) => <div className={className} style={style} {...props} />,
Position: { Left: "left", Right: "right" },
useConnection: () => connectionStateRef.current,
}));
import CanvasHandle from "@/components/canvas/canvas-handle";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function MagnetTargetSetter({
target,
}: {
target:
| {
nodeId: string;
handleId?: string;
handleType: "source" | "target";
centerX: number;
centerY: number;
distancePx: number;
}
| null;
}) {
const { setActiveTarget } = useCanvasConnectionMagnetism();
useEffect(() => {
setActiveTarget(target);
}, [setActiveTarget, target]);
return null;
}
describe("CanvasHandle", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
connectionStateRef.current = { inProgress: false };
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
document.documentElement.classList.remove("dark");
container = null;
root = null;
});
async function renderHandle(args?: {
connectionState?: {
inProgress?: boolean;
fromNode?: { id: string };
fromHandle?: { id?: string; type?: "source" | "target" };
toNode?: { id: string } | null;
toHandle?: { id?: string | null; type?: "source" | "target" } | null;
isValid?: boolean | null;
};
activeTarget?: {
nodeId: string;
handleId?: string;
handleType: "source" | "target";
centerX: number;
centerY: number;
distancePx: number;
} | null;
props?: Partial<React.ComponentProps<typeof CanvasHandle>>;
}) {
connectionStateRef.current = args?.connectionState ?? { inProgress: false };
await act(async () => {
root?.render(
<CanvasConnectionMagnetismProvider>
<MagnetTargetSetter target={args?.activeTarget ?? null} />
<CanvasHandle
nodeId="node-1"
nodeType="image"
type="target"
position={"left" as React.ComponentProps<typeof CanvasHandle>["position"]}
id="image-in"
{...args?.props}
/>
</CanvasConnectionMagnetismProvider>,
);
});
}
function getHandleElement() {
const handle = container?.querySelector("[data-node-id='node-1'][data-handle-type]");
if (!(handle instanceof HTMLElement)) {
throw new Error("CanvasHandle element not found");
}
return handle;
}
it("renders default handle chrome with expected size and border", async () => {
await renderHandle();
const handle = getHandleElement();
expect(handle.className).toContain("!h-3");
expect(handle.className).toContain("!w-3");
expect(handle.className).toContain("!border-2");
expect(handle.className).toContain("!border-background");
expect(handle.getAttribute("data-glow-state")).toBe("idle");
});
it("turns on near-target glow when this handle is active target", async () => {
await renderHandle({
connectionState: { inProgress: true },
activeTarget: {
nodeId: "node-1",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_SNAP_RADIUS_PX + 2,
},
});
const handle = getHandleElement();
expect(handle.getAttribute("data-glow-state")).toBe("near");
});
it("renders a stronger glow in snapped state than near state", async () => {
await renderHandle({
connectionState: { inProgress: true },
activeTarget: {
nodeId: "node-1",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_SNAP_RADIUS_PX + 6,
},
});
const nearHandle = getHandleElement();
const nearGlow = nearHandle.style.boxShadow;
await renderHandle({
connectionState: { inProgress: true },
activeTarget: {
nodeId: "node-1",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_SNAP_RADIUS_PX - 4,
},
});
const snappedHandle = getHandleElement();
expect(snappedHandle.getAttribute("data-glow-state")).toBe("snapped");
expect(snappedHandle.style.boxShadow).not.toBe(nearGlow);
});
it("ramps up glow intensity as pointer gets closer within glow radius", async () => {
await renderHandle({
connectionState: { inProgress: true },
activeTarget: {
nodeId: "node-1",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_GLOW_RADIUS_PX - 1,
},
});
const farHandle = getHandleElement();
const farStrength = Number(farHandle.getAttribute("data-glow-strength") ?? "0");
await renderHandle({
connectionState: { inProgress: true },
activeTarget: {
nodeId: "node-1",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_SNAP_RADIUS_PX + 1,
},
});
const nearHandle = getHandleElement();
const nearStrength = Number(nearHandle.getAttribute("data-glow-strength") ?? "0");
expect(farHandle.getAttribute("data-glow-state")).toBe("near");
expect(nearHandle.getAttribute("data-glow-state")).toBe("near");
expect(nearStrength).toBeGreaterThan(farStrength);
});
it("does not glow for non-target handles during the same drag", async () => {
await renderHandle({
connectionState: { inProgress: true },
activeTarget: {
nodeId: "other-node",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_SNAP_RADIUS_PX - 4,
},
});
const handle = getHandleElement();
expect(handle.getAttribute("data-glow-state")).toBe("idle");
});
it("shows glow while dragging when connection payload exists without inProgress", async () => {
await renderHandle({
connectionState: {
fromNode: { id: "source-node" },
fromHandle: { id: "image-out", type: "source" },
},
activeTarget: {
nodeId: "node-1",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_SNAP_RADIUS_PX + 2,
},
});
const handle = getHandleElement();
expect(handle.getAttribute("data-glow-state")).toBe("near");
});
it("shows glow from native connection hover target even without custom magnet target", async () => {
await renderHandle({
connectionState: {
inProgress: true,
isValid: true,
toNode: { id: "node-1" },
toHandle: { id: "image-in", type: "target" },
},
activeTarget: null,
});
const handle = getHandleElement();
expect(handle.getAttribute("data-glow-state")).toBe("snapped");
});
it("adapts glow rendering between light and dark modes", async () => {
await renderHandle({
connectionState: { inProgress: true },
activeTarget: {
nodeId: "node-1",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_SNAP_RADIUS_PX + 1,
},
});
const lightHandle = getHandleElement();
const lightShadow = lightHandle.style.boxShadow;
const lightMode = lightHandle.getAttribute("data-glow-mode");
document.documentElement.classList.add("dark");
await renderHandle({
connectionState: { inProgress: true },
activeTarget: {
nodeId: "node-1",
handleId: "image-in",
handleType: "target",
centerX: 120,
centerY: 80,
distancePx: HANDLE_SNAP_RADIUS_PX + 1,
},
});
const darkHandle = getHandleElement();
const darkShadow = darkHandle.style.boxShadow;
const darkMode = darkHandle.getAttribute("data-glow-mode");
expect(lightMode).toBe("light");
expect(darkMode).toBe("dark");
expect(darkShadow).not.toBe(lightShadow);
});
it("emits stable handle geometry data attributes", async () => {
await renderHandle({
props: {
nodeId: "node-2",
id: undefined,
type: "source",
position: "right" as React.ComponentProps<typeof CanvasHandle>["position"],
},
});
const handle = container?.querySelector("[data-node-id='node-2'][data-handle-type='source']");
if (!(handle instanceof HTMLElement)) {
throw new Error("CanvasHandle source element not found");
}
expect(handle.getAttribute("data-node-id")).toBe("node-2");
expect(handle.getAttribute("data-handle-id")).toBe("");
expect(handle.getAttribute("data-handle-type")).toBe("source");
});
});

View File

@@ -28,6 +28,31 @@ vi.mock("@xyflow/react", () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState), 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;
}) => (
<div
data-canvas-handle="true"
data-handle-id={id ?? ""}
data-handle-type={type}
data-node-id={nodeId}
data-node-type={nodeType ?? ""}
data-top={typeof style?.top === "string" ? style.top : ""}
/>
),
}));
vi.mock("../nodes/base-node-wrapper", () => ({ vi.mock("../nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
})); }));
@@ -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%"');
});
}); });

View File

@@ -0,0 +1,310 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import {
ConnectionLineType,
Position,
type ConnectionLineComponentProps,
} from "@xyflow/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CanvasConnectionMagnetismProvider,
} from "@/components/canvas/canvas-connection-magnetism-context";
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
import { connectionLineAccentRgb } from "@/lib/canvas-utils";
const reactFlowStateRef: {
current: {
nodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: object }>;
edges: Array<{ id: string; source: string; target: string; targetHandle?: string | null }>;
screenToFlowPosition: ({ x, y }: { x: number; y: number }) => { x: number; y: number };
};
} = {
current: {
nodes: [],
edges: [],
screenToFlowPosition: ({ x, y }) => ({ x, y }),
},
};
const connectionStateRef: {
current: {
fromHandle?: { type?: "source" | "target" };
};
} = {
current: {
fromHandle: { type: "source" },
},
};
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual<typeof import("@xyflow/react")>("@xyflow/react");
return {
...actual,
useReactFlow: () => ({
getNodes: () => reactFlowStateRef.current.nodes,
getEdges: () => reactFlowStateRef.current.edges,
screenToFlowPosition: reactFlowStateRef.current.screenToFlowPosition,
}),
useConnection: () => connectionStateRef.current,
};
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const baseProps = {
connectionLineType: ConnectionLineType.Straight,
fromNode: {
id: "source-node",
type: "image",
},
fromHandle: {
id: "image-out",
type: "source",
nodeId: "source-node",
position: Position.Right,
x: 0,
y: 0,
width: 12,
height: 12,
},
fromX: 20,
fromY: 40,
toX: 290,
toY: 210,
fromPosition: Position.Right,
toPosition: Position.Left,
connectionStatus: "valid",
} as unknown as ConnectionLineComponentProps;
describe("CustomConnectionLine", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
container?.remove();
document
.querySelectorAll("[data-testid='custom-line-magnet-handle']")
.forEach((element) => element.remove());
document.documentElement.classList.remove("dark");
container = null;
root = null;
});
function renderLine(args?: {
withMagnetHandle?: boolean;
connectionStatus?: ConnectionLineComponentProps["connectionStatus"];
omitFromHandleType?: boolean;
toX?: number;
toY?: number;
pointer?: { x: number; y: number };
}) {
document
.querySelectorAll("[data-testid='custom-line-magnet-handle']")
.forEach((element) => element.remove());
reactFlowStateRef.current = {
nodes: [
{ id: "source-node", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} },
],
edges: [],
screenToFlowPosition: ({ x, y }) => ({ x, y }),
};
connectionStateRef.current = {
fromHandle: { type: "source" },
};
if (args?.withMagnetHandle && container) {
const handleEl = document.createElement("div");
handleEl.setAttribute("data-testid", "custom-line-magnet-handle");
handleEl.setAttribute("data-node-id", "target-node");
handleEl.setAttribute("data-handle-id", "");
handleEl.setAttribute("data-handle-type", "target");
handleEl.getBoundingClientRect = () =>
({
x: 294,
y: 214,
top: 214,
left: 294,
right: 306,
bottom: 226,
width: 12,
height: 12,
toJSON: () => ({}),
}) as DOMRect;
document.body.appendChild(handleEl);
}
act(() => {
const lineProps = {
...baseProps,
...(args?.toX !== undefined ? { toX: args.toX } : null),
...(args?.toY !== undefined ? { toY: args.toY } : null),
...(args?.pointer ? { pointer: args.pointer } : null),
fromHandle: {
...baseProps.fromHandle,
...(args?.omitFromHandleType ? { type: undefined } : null),
},
} as ConnectionLineComponentProps;
root?.render(
<CanvasConnectionMagnetismProvider>
<svg>
<CustomConnectionLine
{...lineProps}
connectionStatus={args?.connectionStatus ?? "valid"}
/>
</svg>
</CanvasConnectionMagnetismProvider>,
);
});
}
function getPath() {
const path = container?.querySelector("path");
if (!(path instanceof Element) || path.tagName.toLowerCase() !== "path") {
throw new Error("Connection line path not rendered");
}
return path as SVGElement;
}
it("renders with the existing accent color when no magnet target is active", () => {
renderLine();
const [r, g, b] = connectionLineAccentRgb("image", "image-out");
const path = getPath();
expect(path.style.stroke).toBe(`rgb(${r}, ${g}, ${b})`);
expect(path.getAttribute("d")).toContain("290");
expect(path.getAttribute("d")).toContain("210");
});
it("snaps endpoint to active magnet target center", () => {
renderLine({
withMagnetHandle: true,
});
const path = getPath();
expect(path.getAttribute("d")).toContain("300");
expect(path.getAttribute("d")).toContain("220");
});
it("still resolves magnet target when fromHandle.type is missing", () => {
renderLine({
withMagnetHandle: true,
omitFromHandleType: true,
});
const path = getPath();
expect(path.getAttribute("d")).toContain("300");
expect(path.getAttribute("d")).toContain("220");
});
it("strengthens stroke visual feedback while snapped", () => {
renderLine();
const idlePath = getPath();
const idleStrokeWidth = idlePath.style.strokeWidth;
const idleFilter = idlePath.style.filter;
renderLine({
withMagnetHandle: true,
});
const snappedPath = getPath();
expect(snappedPath.style.strokeWidth).not.toBe(idleStrokeWidth);
expect(snappedPath.style.filter).not.toBe(idleFilter);
});
it("ramps stroke feedback up as pointer gets closer before snap", () => {
renderLine({
withMagnetHandle: true,
toX: 252,
toY: 220,
pointer: { x: 252, y: 220 },
});
const farNearPath = getPath();
const farNearWidth = Number(farNearPath.style.strokeWidth || "0");
renderLine({
withMagnetHandle: true,
toX: 266,
toY: 220,
pointer: { x: 266, y: 220 },
});
const closeNearPath = getPath();
const closeNearWidth = Number(closeNearPath.style.strokeWidth || "0");
expect(farNearWidth).toBeGreaterThan(2.5);
expect(closeNearWidth).toBeGreaterThan(farNearWidth);
});
it("keeps invalid connection opacity behavior while snapped", () => {
renderLine({
withMagnetHandle: true,
connectionStatus: "invalid",
});
const path = getPath();
expect(path.style.opacity).toBe("0.45");
});
it("uses client pointer coordinates for magnet lookup and converts snapped endpoint back to flow space", () => {
reactFlowStateRef.current.screenToFlowPosition = ({ x, y }) => ({
x: Math.round(x / 10),
y: Math.round(y / 10),
});
renderLine({
withMagnetHandle: true,
toX: 29,
toY: 21,
pointer: { x: 300, y: 220 },
});
const path = getPath();
expect(path.getAttribute("d")).toContain("30");
expect(path.getAttribute("d")).toContain("22");
});
it("adjusts glow filter between light and dark mode", () => {
renderLine({
withMagnetHandle: true,
toX: 266,
toY: 220,
pointer: { x: 266, y: 220 },
});
const lightPath = getPath();
const lightFilter = lightPath.style.filter;
document.documentElement.classList.add("dark");
renderLine({
withMagnetHandle: true,
toX: 266,
toY: 220,
pointer: { x: 266, y: 220 },
});
const darkPath = getPath();
const darkFilter = darkPath.style.filter;
expect(lightFilter).not.toBe("");
expect(darkFilter).not.toBe("");
expect(darkFilter).not.toBe(lightFilter);
});
});

View File

@@ -17,6 +17,31 @@ vi.mock("@xyflow/react", () => ({
Position: { Left: "left", Right: "right" }, 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;
}) => (
<div
data-canvas-handle="true"
data-handle-id={id ?? ""}
data-handle-type={type}
data-node-id={nodeId}
data-node-type={nodeType ?? ""}
data-top={typeof style?.top === "string" ? style.top : ""}
/>
),
}));
vi.mock("@/components/canvas/canvas-sync-context", () => ({ vi.mock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({ useCanvasSync: () => ({
queueNodeDataUpdate: mocks.queueNodeDataUpdate, queueNodeDataUpdate: mocks.queueNodeDataUpdate,
@@ -222,8 +247,20 @@ describe("MixerNode", () => {
it("renders expected mixer handles", async () => { it("renders expected mixer handles", async () => {
await renderNode(); await renderNode();
expect(container?.querySelector('[data-handle-id="base"][data-handle-type="target"]')).toBeTruthy(); expect(
expect(container?.querySelector('[data-handle-id="overlay"][data-handle-type="target"]')).toBeTruthy(); container?.querySelector(
expect(container?.querySelector('[data-handle-id="mixer-out"][data-handle-type="source"]')).toBeTruthy(); '[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();
}); });
}); });

View File

@@ -6,9 +6,11 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
resolveDroppedConnectionTarget: vi.fn(), resolveDroppedConnectionTarget: vi.fn(),
resolveCanvasMagnetTarget: vi.fn(),
})); }));
vi.mock("@/components/canvas/canvas-helpers", async () => { vi.mock("@/components/canvas/canvas-helpers", async () => {
@@ -22,8 +24,23 @@ vi.mock("@/components/canvas/canvas-helpers", async () => {
}; };
}); });
vi.mock("@/components/canvas/canvas-connection-magnetism", async () => {
const actual = await vi.importActual<
typeof import("@/components/canvas/canvas-connection-magnetism")
>("@/components/canvas/canvas-connection-magnetism");
return {
...actual,
resolveCanvasMagnetTarget: mocks.resolveCanvasMagnetTarget,
};
});
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections"; import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers"; import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
import {
CanvasConnectionMagnetismProvider,
useCanvasConnectionMagnetism,
} from "@/components/canvas/canvas-connection-magnetism-context";
import { nodeTypes } from "@/components/canvas/node-types"; import { nodeTypes } from "@/components/canvas/node-types";
import { NODE_CATALOG } from "@/lib/canvas-node-catalog"; import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates"; import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
@@ -35,6 +52,14 @@ const latestHandlersRef: {
current: ReturnType<typeof useCanvasConnections> | null; current: ReturnType<typeof useCanvasConnections> | null;
} = { current: null }; } = { current: null };
const latestMagnetTargetRef: {
current: CanvasMagnetTarget | null;
} = { current: null };
const latestSetActiveTargetRef: {
current: ((target: CanvasMagnetTarget | null) => void) | null;
} = { current: null };
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type HookHarnessProps = { type HookHarnessProps = {
@@ -47,9 +72,12 @@ type HookHarnessProps = {
setEdgesMock?: ReturnType<typeof vi.fn>; setEdgesMock?: ReturnType<typeof vi.fn>;
nodes?: RFNode[]; nodes?: RFNode[];
edges?: RFEdge[]; edges?: RFEdge[];
initialMagnetTarget?: CanvasMagnetTarget | null;
}; };
function HookHarness({ type HookHarnessInnerProps = HookHarnessProps;
function HookHarnessInner({
helperResult, helperResult,
runCreateEdgeMutation = vi.fn(async () => undefined), runCreateEdgeMutation = vi.fn(async () => undefined),
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined), runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
@@ -59,7 +87,10 @@ function HookHarness({
setEdgesMock, setEdgesMock,
nodes: providedNodes, nodes: providedNodes,
edges: providedEdges, edges: providedEdges,
}: HookHarnessProps) { initialMagnetTarget,
}: HookHarnessInnerProps) {
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const didInitializeMagnetTargetRef = useRef(false);
const [nodes] = useState<RFNode[]>( const [nodes] = useState<RFNode[]>(
providedNodes ?? [ providedNodes ?? [
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
@@ -88,6 +119,17 @@ function HookHarness({
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult); mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
}, [helperResult]); }, [helperResult]);
useEffect(() => {
mocks.resolveCanvasMagnetTarget.mockReturnValue(null);
}, []);
useEffect(() => {
if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) {
didInitializeMagnetTargetRef.current = true;
setActiveTarget(initialMagnetTarget);
}
}, [initialMagnetTarget, setActiveTarget]);
const handlers = useCanvasConnections({ const handlers = useCanvasConnections({
canvasId: asCanvasId("canvas-1"), canvasId: asCanvasId("canvas-1"),
nodes, nodes,
@@ -115,15 +157,36 @@ function HookHarness({
latestHandlersRef.current = handlers; latestHandlersRef.current = handlers;
}, [handlers]); }, [handlers]);
useEffect(() => {
latestMagnetTargetRef.current = activeTarget;
}, [activeTarget]);
useEffect(() => {
latestSetActiveTargetRef.current = setActiveTarget;
return () => {
latestSetActiveTargetRef.current = null;
};
}, [setActiveTarget]);
return null; return null;
} }
function HookHarness(props: HookHarnessProps) {
return (
<CanvasConnectionMagnetismProvider>
<HookHarnessInner {...props} />
</CanvasConnectionMagnetismProvider>
);
}
describe("useCanvasConnections", () => { describe("useCanvasConnections", () => {
let container: HTMLDivElement | null = null; let container: HTMLDivElement | null = null;
let root: Root | null = null; let root: Root | null = null;
afterEach(async () => { afterEach(async () => {
latestHandlersRef.current = null; latestHandlersRef.current = null;
latestMagnetTargetRef.current = null;
latestSetActiveTargetRef.current = null;
vi.clearAllMocks(); vi.clearAllMocks();
if (root) { if (root) {
await act(async () => { await act(async () => {
@@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => {
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled(); expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
}); });
it("falls back to active magnet target when direct drop resolution misses", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
initialMagnetTarget={{
nodeId: "node-target",
handleId: "base",
handleType: "target",
centerX: 320,
centerY: 180,
distancePx: 12,
}}
nodes={[
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 400, clientY: 260 } as MouseEvent,
{
isValid: false,
from: { x: 0, y: 0 },
fromNode: { id: "node-source", type: "image" },
fromHandle: { id: null, type: "source" },
fromPosition: null,
to: { x: 400, y: 260 },
toHandle: null,
toNode: null,
toPosition: null,
pointer: null,
} as never,
);
});
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
canvasId: "canvas-1",
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: "base",
});
expect(latestMagnetTargetRef.current).toBeNull();
});
it("rejects invalid active magnet target and clears transient state", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
const showConnectionRejectedToast = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
initialMagnetTarget={{
nodeId: "node-source",
handleType: "target",
centerX: 100,
centerY: 100,
distancePx: 10,
}}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 120, clientY: 120 } as MouseEvent,
{
isValid: false,
from: { x: 0, y: 0 },
fromNode: { id: "node-source", type: "image" },
fromHandle: { id: null, type: "source" },
fromPosition: null,
to: { x: 120, y: 120 },
toHandle: null,
toNode: null,
toPosition: null,
pointer: null,
} as never,
);
});
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
expect(latestMagnetTargetRef.current).toBeNull();
});
it("clears transient magnet state when dropping on background opens menu", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
helperResult={null}
initialMagnetTarget={{
nodeId: "node-target",
handleType: "target",
centerX: 200,
centerY: 220,
distancePx: 14,
}}
/>,
);
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
});
await act(async () => {
latestHandlersRef.current?.onConnectEnd(
{ clientX: 500, clientY: 460 } as MouseEvent,
{
isValid: false,
from: { x: 0, y: 0 },
fromNode: { id: "node-source", type: "image" },
fromHandle: { id: null, type: "source" },
fromPosition: null,
to: { x: 500, y: 460 },
toHandle: null,
toNode: null,
toPosition: null,
pointer: null,
} as never,
);
});
expect(latestHandlersRef.current?.connectionDropMenu).toEqual(
expect.objectContaining({
screenX: 500,
screenY: 460,
}),
);
expect(latestMagnetTargetRef.current).toBeNull();
});
it("clears transient magnet state when reconnect drag ends", async () => {
const runCreateEdgeMutation = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
edges={[
{
id: "edge-1",
source: "node-source",
target: "node-target",
targetHandle: "base",
},
]}
nodes={[
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
initialMagnetTarget={{
nodeId: "node-target",
handleType: "target",
handleId: "overlay",
centerX: 300,
centerY: 180,
distancePx: 11,
}}
/>,
);
});
const oldEdge = {
id: "edge-1",
source: "node-source",
target: "node-target",
targetHandle: "base",
} as RFEdge;
await act(async () => {
latestHandlersRef.current?.onReconnectStart();
latestHandlersRef.current?.onReconnect(oldEdge, {
source: "node-source",
target: "node-target",
sourceHandle: null,
targetHandle: "overlay",
});
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
await Promise.resolve();
});
expect(runCreateEdgeMutation).toHaveBeenCalled();
expect(latestMagnetTargetRef.current).toBeNull();
});
}); });

View File

@@ -699,6 +699,7 @@ describe("favorite retention in strict local node flows", () => {
vi.doMock("@xyflow/react", () => ({ vi.doMock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
const importedModule = (await import(modulePath)) as { const importedModule = (await import(modulePath)) as {

View File

@@ -0,0 +1,51 @@
"use client";
import {
createContext,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
type CanvasConnectionMagnetismState = {
activeTarget: CanvasMagnetTarget | null;
setActiveTarget: (target: CanvasMagnetTarget | null) => void;
};
const CanvasConnectionMagnetismContext =
createContext<CanvasConnectionMagnetismState | null>(null);
const FALLBACK_MAGNETISM_STATE: CanvasConnectionMagnetismState = {
activeTarget: null,
setActiveTarget: () => undefined,
};
export function CanvasConnectionMagnetismProvider({
children,
}: {
children: ReactNode;
}) {
const [activeTarget, setActiveTarget] = useState<CanvasMagnetTarget | null>(null);
const value = useMemo(
() => ({
activeTarget,
setActiveTarget,
}),
[activeTarget],
);
return (
<CanvasConnectionMagnetismContext.Provider value={value}>
{children}
</CanvasConnectionMagnetismContext.Provider>
);
}
export function useCanvasConnectionMagnetism(): CanvasConnectionMagnetismState {
const context = useContext(CanvasConnectionMagnetismContext);
return context ?? FALLBACK_MAGNETISM_STATE;
}

View File

@@ -0,0 +1,244 @@
import type { Connection, Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { validateCanvasConnectionPolicy } from "@/lib/canvas-connection-policy";
export const HANDLE_GLOW_RADIUS_PX = 56;
export const HANDLE_SNAP_RADIUS_PX = 40;
function clamp01(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
if (value <= 0) {
return 0;
}
if (value >= 1) {
return 1;
}
return value;
}
function smoothstep(value: number): number {
const v = clamp01(value);
return v * v * (3 - 2 * v);
}
export function resolveCanvasGlowStrength(args: {
distancePx: number;
glowRadiusPx?: number;
snapRadiusPx?: number;
}): number {
const glowRadius = args.glowRadiusPx ?? HANDLE_GLOW_RADIUS_PX;
const snapRadius = args.snapRadiusPx ?? HANDLE_SNAP_RADIUS_PX;
if (!Number.isFinite(args.distancePx)) {
return 0;
}
if (args.distancePx <= 0) {
return 1;
}
if (args.distancePx >= glowRadius) {
return 0;
}
if (args.distancePx <= snapRadius) {
return 1;
}
const preSnapRange = Math.max(1, glowRadius - snapRadius);
const progressToSnap = (glowRadius - args.distancePx) / preSnapRange;
const eased = smoothstep(progressToSnap);
return 0.22 + eased * 0.68;
}
export type CanvasMagnetTarget = {
nodeId: string;
handleId?: string;
handleType: "source" | "target";
centerX: number;
centerY: number;
distancePx: number;
};
type HandleCandidate = CanvasMagnetTarget & {
index: number;
};
function isOptimisticEdgeId(id: string): boolean {
return id.startsWith("optimistic_edge_");
}
function normalizeHandleId(value: string | undefined): string | undefined {
if (value === undefined || value === "") {
return undefined;
}
return value;
}
function isValidConnectionCandidate(args: {
connection: Connection;
nodes: RFNode[];
edges: RFEdge[];
}): boolean {
const { connection, nodes, edges } = args;
if (!connection.source || !connection.target) {
return false;
}
if (connection.source === connection.target) {
return false;
}
const sourceNode = nodes.find((node) => node.id === connection.source);
const targetNode = nodes.find((node) => node.id === connection.target);
if (!sourceNode || !targetNode) {
return false;
}
const incomingEdges = edges.filter(
(edge) =>
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id) &&
edge.target === connection.target,
);
return (
validateCanvasConnectionPolicy({
sourceType: sourceNode.type ?? "",
targetType: targetNode.type ?? "",
targetHandle: connection.targetHandle,
targetIncomingCount: incomingEdges.length,
targetIncomingHandles: incomingEdges.map((edge) => edge.targetHandle),
}) === null
);
}
function collectHandleCandidates(args: {
point: { x: number; y: number };
expectedHandleType: "source" | "target";
maxDistancePx: number;
handleElements?: Element[];
}): HandleCandidate[] {
const { point, expectedHandleType, maxDistancePx } = args;
const handleElements =
args.handleElements ??
(typeof document === "undefined"
? []
: Array.from(document.querySelectorAll("[data-node-id][data-handle-type]")));
const candidates: HandleCandidate[] = [];
let index = 0;
for (const element of handleElements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.dataset.handleType !== expectedHandleType) {
continue;
}
const nodeId = element.dataset.nodeId;
if (!nodeId) {
continue;
}
const rect = element.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distancePx = Math.hypot(point.x - centerX, point.y - centerY);
if (distancePx > maxDistancePx) {
continue;
}
candidates.push({
index,
nodeId,
handleId: normalizeHandleId(element.dataset.handleId),
handleType: expectedHandleType,
centerX,
centerY,
distancePx,
});
index += 1;
}
return candidates;
}
function toConnectionFromCandidate(args: {
fromNodeId: string;
fromHandleId?: string;
fromHandleType: "source" | "target";
candidate: HandleCandidate;
}): Connection {
if (args.fromHandleType === "source") {
return {
source: args.fromNodeId,
sourceHandle: args.fromHandleId ?? null,
target: args.candidate.nodeId,
targetHandle: args.candidate.handleId ?? null,
};
}
return {
source: args.candidate.nodeId,
sourceHandle: args.candidate.handleId ?? null,
target: args.fromNodeId,
targetHandle: args.fromHandleId ?? null,
};
}
export function resolveCanvasMagnetTarget(args: {
point: { x: number; y: number };
fromNodeId: string;
fromHandleId?: string;
fromHandleType: "source" | "target";
nodes: RFNode[];
edges: RFEdge[];
maxDistancePx?: number;
handleElements?: Element[];
}): CanvasMagnetTarget | null {
const expectedHandleType = args.fromHandleType === "source" ? "target" : "source";
const maxDistancePx = args.maxDistancePx ?? HANDLE_GLOW_RADIUS_PX;
const candidates = collectHandleCandidates({
point: args.point,
expectedHandleType,
maxDistancePx,
handleElements: args.handleElements,
}).filter((candidate) => {
const connection = toConnectionFromCandidate({
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId,
fromHandleType: args.fromHandleType,
candidate,
});
return isValidConnectionCandidate({
connection,
nodes: args.nodes,
edges: args.edges,
});
});
if (candidates.length === 0) {
return null;
}
candidates.sort((a, b) => {
const distanceDelta = a.distancePx - b.distancePx;
if (Math.abs(distanceDelta) > Number.EPSILON) {
return distanceDelta;
}
return a.index - b.index;
});
const winner = candidates[0];
return {
nodeId: winner.nodeId,
handleId: winner.handleId,
handleType: winner.handleType,
centerX: winner.centerX,
centerY: winner.centerY,
distancePx: winner.distancePx,
};
}

View File

@@ -0,0 +1,146 @@
"use client";
import { Handle, useConnection } from "@xyflow/react";
import {
resolveCanvasGlowStrength,
} from "@/components/canvas/canvas-connection-magnetism";
import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context";
import {
canvasHandleAccentColor,
canvasHandleGlowShadow,
type EdgeGlowColorMode,
} from "@/lib/canvas-utils";
import { cn } from "@/lib/utils";
type ReactFlowHandleProps = React.ComponentProps<typeof Handle>;
type CanvasHandleProps = Omit<ReactFlowHandleProps, "id"> & {
nodeId: string;
nodeType?: string;
id?: string;
};
function normalizeHandleId(value: string | undefined): string | undefined {
return value === "" ? undefined : value;
}
export default function CanvasHandle({
nodeId,
nodeType,
id,
type,
className,
style,
...rest
}: CanvasHandleProps) {
const connection = useConnection();
const { activeTarget } = useCanvasConnectionMagnetism();
const connectionState = connection as {
inProgress?: boolean;
isValid?: boolean | null;
fromNode?: unknown;
toNode?: unknown;
fromHandle?: unknown;
toHandle?: unknown;
};
const hasConnectionPayload =
connectionState.fromNode !== undefined ||
connectionState.toNode !== undefined ||
connectionState.fromHandle !== undefined ||
connectionState.toHandle !== undefined;
const isConnectionDragActive =
connectionState.inProgress === true ||
(connectionState.inProgress === undefined && hasConnectionPayload);
const handleId = normalizeHandleId(id);
const targetHandleId = normalizeHandleId(activeTarget?.handleId);
const toNodeId =
connectionState.toNode &&
typeof connectionState.toNode === "object" &&
"id" in connectionState.toNode &&
typeof (connectionState.toNode as { id?: unknown }).id === "string"
? ((connectionState.toNode as { id: string }).id ?? null)
: null;
const toHandleMeta =
connectionState.toHandle && typeof connectionState.toHandle === "object"
? (connectionState.toHandle as { id?: string | null; type?: "source" | "target" })
: null;
const toHandleId = normalizeHandleId(
toHandleMeta?.id === null ? undefined : toHandleMeta?.id,
);
const toHandleType =
toHandleMeta?.type === "source" || toHandleMeta?.type === "target"
? toHandleMeta.type
: null;
const colorMode: EdgeGlowColorMode =
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? "dark"
: "light";
const isActiveTarget =
isConnectionDragActive &&
activeTarget !== null &&
activeTarget.nodeId === nodeId &&
activeTarget.handleType === type &&
targetHandleId === handleId;
const isNativeHoverTarget =
connectionState.inProgress === true &&
toNodeId === nodeId &&
toHandleType === type &&
toHandleId === handleId;
let glowStrength = 0;
if (isActiveTarget) {
glowStrength = resolveCanvasGlowStrength({
distancePx: activeTarget.distancePx,
});
} else if (isNativeHoverTarget) {
glowStrength = connectionState.isValid === true ? 1 : 0.68;
}
const glowState: "idle" | "near" | "snapped" =
glowStrength <= 0 ? "idle" : glowStrength >= 0.96 ? "snapped" : "near";
const accentColor = canvasHandleAccentColor({
nodeType,
handleId,
handleType: type,
});
const boxShadow = canvasHandleGlowShadow({
nodeType,
handleId,
handleType: type,
strength: glowStrength,
colorMode,
});
return (
<Handle
{...rest}
id={id}
type={type}
className={cn(
"!h-3 !w-3 !border-2 !border-background transition-[box-shadow,background-color] duration-150",
className,
)}
style={{
...style,
backgroundColor: accentColor,
boxShadow,
}}
data-node-id={nodeId}
data-handle-id={id ?? ""}
data-handle-type={type}
data-glow-state={glowState}
data-glow-strength={glowStrength.toFixed(3)}
data-glow-mode={colorMode}
/>
);
}

View File

@@ -8,6 +8,7 @@ import {
getSourceImageFromGraph, getSourceImageFromGraph,
} from "@/lib/canvas-render-preview"; } from "@/lib/canvas-render-preview";
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils"; import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
import { resolveCanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
export const OPTIMISTIC_NODE_PREFIX = "optimistic_"; export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_"; export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
@@ -421,17 +422,7 @@ export function resolveDroppedConnectionTarget(args: {
? [] ? []
: document.elementsFromPoint(args.point.x, args.point.y); : document.elementsFromPoint(args.point.x, args.point.y);
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint); const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
if (!nodeElement) { if (nodeElement) {
logCanvasConnectionDebug("drop-target:node-missed", {
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId ?? null,
fromHandleType: args.fromHandleType,
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
});
return null;
}
const targetNodeId = nodeElement.dataset.id; const targetNodeId = nodeElement.dataset.id;
if (!targetNodeId) { if (!targetNodeId) {
logCanvasConnectionDebug("drop-target:node-missing-data-id", { logCanvasConnectionDebug("drop-target:node-missing-data-id", {
@@ -511,6 +502,65 @@ export function resolveDroppedConnectionTarget(args: {
return droppedConnection; return droppedConnection;
} }
const magnetTarget = resolveCanvasMagnetTarget({
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId,
fromHandleType: args.fromHandleType,
nodes: args.nodes,
edges: args.edges,
});
if (!magnetTarget) {
logCanvasConnectionDebug("drop-target:node-missed", {
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId ?? null,
fromHandleType: args.fromHandleType,
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
});
return null;
}
if (args.fromHandleType === "source") {
const droppedConnection = {
sourceNodeId: args.fromNodeId,
targetNodeId: magnetTarget.nodeId,
sourceHandle: args.fromHandleId,
targetHandle: magnetTarget.handleId,
};
logCanvasConnectionDebug("drop-target:magnet-detected", {
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId ?? null,
fromHandleType: args.fromHandleType,
magnetTarget,
resolvedConnection: droppedConnection,
});
return droppedConnection;
}
const droppedConnection = {
sourceNodeId: magnetTarget.nodeId,
targetNodeId: args.fromNodeId,
sourceHandle: magnetTarget.handleId,
targetHandle: args.fromHandleId,
};
logCanvasConnectionDebug("drop-target:magnet-detected", {
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId ?? null,
fromHandleType: args.fromHandleType,
magnetTarget,
resolvedConnection: droppedConnection,
});
return droppedConnection;
}
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */ /** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
export type PendingEdgeSplit = { export type PendingEdgeSplit = {
intersectedEdgeId: Id<"edges">; intersectedEdgeId: Id<"edges">;

View File

@@ -39,6 +39,7 @@ type UseCanvasReconnectHandlersParams = {
nextOtherEdgeHandle: "base" | "overlay"; nextOtherEdgeHandle: "base" | "overlay";
} | null; } | null;
onInvalidConnection?: (message: string) => void; onInvalidConnection?: (message: string) => void;
clearActiveMagnetTarget?: () => void;
}; };
export function useCanvasReconnectHandlers({ export function useCanvasReconnectHandlers({
@@ -52,6 +53,7 @@ export function useCanvasReconnectHandlers({
validateConnection, validateConnection,
resolveMixerSwapReconnect, resolveMixerSwapReconnect,
onInvalidConnection, onInvalidConnection,
clearActiveMagnetTarget,
}: UseCanvasReconnectHandlersParams): { }: UseCanvasReconnectHandlersParams): {
onReconnectStart: () => void; onReconnectStart: () => void;
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
@@ -72,10 +74,11 @@ export function useCanvasReconnectHandlers({
>(null); >(null);
const onReconnectStart = useCallback(() => { const onReconnectStart = useCallback(() => {
clearActiveMagnetTarget?.();
edgeReconnectSuccessful.current = false; edgeReconnectSuccessful.current = false;
isReconnectDragActiveRef.current = true; isReconnectDragActiveRef.current = true;
pendingReconnectRef.current = null; pendingReconnectRef.current = null;
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]); }, [clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef]);
const onReconnect = useCallback( const onReconnect = useCallback(
(oldEdge: RFEdge, newConnection: Connection) => { (oldEdge: RFEdge, newConnection: Connection) => {
@@ -201,11 +204,13 @@ export function useCanvasReconnectHandlers({
edgeReconnectSuccessful.current = true; edgeReconnectSuccessful.current = true;
} finally { } finally {
clearActiveMagnetTarget?.();
isReconnectDragActiveRef.current = false; isReconnectDragActiveRef.current = false;
} }
}, },
[ [
canvasId, canvasId,
clearActiveMagnetTarget,
edgeReconnectSuccessful, edgeReconnectSuccessful,
isReconnectDragActiveRef, isReconnectDragActiveRef,
runCreateEdgeMutation, runCreateEdgeMutation,

View File

@@ -78,6 +78,8 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
import { HANDLE_GLOW_RADIUS_PX } from "./canvas-connection-magnetism";
import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context";
interface CanvasInnerProps { interface CanvasInnerProps {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
@@ -675,6 +677,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
panOnDrag={flowPanOnDrag} panOnDrag={flowPanOnDrag}
selectionOnDrag={flowSelectionOnDrag} selectionOnDrag={flowSelectionOnDrag}
panActivationKeyCode="Space" panActivationKeyCode="Space"
connectionRadius={HANDLE_GLOW_RADIUS_PX}
reconnectRadius={24}
edgesReconnectable
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
colorMode={resolvedTheme === "dark" ? "dark" : "light"} colorMode={resolvedTheme === "dark" ? "dark" : "light"}
className={cn( className={cn(
@@ -709,7 +714,9 @@ interface CanvasProps {
export default function Canvas({ canvasId }: CanvasProps) { export default function Canvas({ canvasId }: CanvasProps) {
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<CanvasConnectionMagnetismProvider>
<CanvasInner canvasId={canvasId} /> <CanvasInner canvasId={canvasId} />
</CanvasConnectionMagnetismProvider>
</ReactFlowProvider> </ReactFlowProvider>
); );
} }

View File

@@ -7,8 +7,43 @@ import {
getSmoothStepPath, getSmoothStepPath,
getStraightPath, getStraightPath,
type ConnectionLineComponentProps, type ConnectionLineComponentProps,
useConnection,
useReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { connectionLineAccentRgb } from "@/lib/canvas-utils"; import { useEffect, useMemo } from "react";
import {
HANDLE_SNAP_RADIUS_PX,
resolveCanvasGlowStrength,
resolveCanvasMagnetTarget,
} from "@/components/canvas/canvas-connection-magnetism";
import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context";
import {
connectionLineAccentRgb,
connectionLineGlowFilter,
type EdgeGlowColorMode,
} from "@/lib/canvas-utils";
function hasSameMagnetTarget(
a: Parameters<ReturnType<typeof useCanvasConnectionMagnetism>["setActiveTarget"]>[0],
b: Parameters<ReturnType<typeof useCanvasConnectionMagnetism>["setActiveTarget"]>[0],
): boolean {
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
return (
a.nodeId === b.nodeId &&
a.handleId === b.handleId &&
a.handleType === b.handleType &&
a.centerX === b.centerX &&
a.centerY === b.centerY &&
a.distancePx === b.distancePx
);
}
export default function CustomConnectionLine({ export default function CustomConnectionLine({
connectionLineType, connectionLineType,
@@ -21,13 +56,74 @@ export default function CustomConnectionLine({
fromPosition, fromPosition,
toPosition, toPosition,
connectionStatus, connectionStatus,
pointer,
}: ConnectionLineComponentProps) { }: ConnectionLineComponentProps) {
const { getNodes, getEdges, screenToFlowPosition } = useReactFlow();
const connection = useConnection();
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const fromHandleId = fromHandle?.id;
const fromNodeId = fromNode?.id;
const connectionFromHandleType =
connection.fromHandle?.type === "source" || connection.fromHandle?.type === "target"
? connection.fromHandle.type
: null;
const fromHandleType =
fromHandle?.type === "source" || fromHandle?.type === "target"
? fromHandle.type
: connectionFromHandleType ?? "source";
const resolvedMagnetTarget = useMemo(() => {
if (!fromHandleType || !fromNodeId) {
return null;
}
const magnetPoint =
pointer && Number.isFinite(pointer.x) && Number.isFinite(pointer.y)
? { x: pointer.x, y: pointer.y }
: { x: toX, y: toY };
return resolveCanvasMagnetTarget({
point: magnetPoint,
fromNodeId,
fromHandleId: fromHandleId ?? undefined,
fromHandleType,
nodes: getNodes(),
edges: getEdges(),
});
}, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, pointer, toX, toY]);
useEffect(() => {
if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) {
return;
}
setActiveTarget(resolvedMagnetTarget);
}, [activeTarget, resolvedMagnetTarget, setActiveTarget]);
const magnetTarget = activeTarget ?? resolvedMagnetTarget;
const glowStrength = magnetTarget
? resolveCanvasGlowStrength({
distancePx: magnetTarget.distancePx,
})
: 0;
const snappedTarget =
magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX
? magnetTarget
: null;
const snappedFlowPoint =
snappedTarget === null
? null
: screenToFlowPosition({ x: snappedTarget.centerX, y: snappedTarget.centerY });
const targetX = snappedFlowPoint?.x ?? toX;
const targetY = snappedFlowPoint?.y ?? toY;
const pathParams = { const pathParams = {
sourceX: fromX, sourceX: fromX,
sourceY: fromY, sourceY: fromY,
sourcePosition: fromPosition, sourcePosition: fromPosition,
targetX: toX, targetX,
targetY: toY, targetY,
targetPosition: toPosition, targetPosition: toPosition,
}; };
@@ -52,8 +148,19 @@ export default function CustomConnectionLine({
[path] = getStraightPath(pathParams); [path] = getStraightPath(pathParams);
} }
const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id); const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandleId);
const opacity = connectionStatus === "invalid" ? 0.45 : 1; const opacity = connectionStatus === "invalid" ? 0.45 : 1;
const colorMode: EdgeGlowColorMode =
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? "dark"
: "light";
const strokeWidth = 2.5 + glowStrength * 0.75;
const filter = connectionLineGlowFilter({
nodeType: fromNode.type,
handleId: fromHandleId,
strength: glowStrength,
colorMode,
});
return ( return (
<path <path
@@ -62,9 +169,10 @@ export default function CustomConnectionLine({
className="ls-connection-line-marching" className="ls-connection-line-marching"
style={{ style={{
stroke: `rgb(${r}, ${g}, ${b})`, stroke: `rgb(${r}, ${g}, ${b})`,
strokeWidth: 2.5, strokeWidth,
strokeLinecap: "round", strokeLinecap: "round",
strokeDasharray: "10 8", strokeDasharray: "10 8",
filter,
opacity, opacity,
}} }}
/> />

View File

@@ -2,7 +2,7 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Bot } from "lucide-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 { useAction } from "convex/react";
import type { FunctionReference } from "convex/server"; import type { FunctionReference } from "convex/server";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -33,6 +33,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import CanvasHandle from "@/components/canvas/canvas-handle";
type AgentNodeData = { type AgentNodeData = {
templateId?: string; templateId?: string;
@@ -466,13 +467,17 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
statusMessage={nodeData._statusMessage} statusMessage={nodeData._statusMessage}
className="min-w-[300px] border-amber-500/30" className="min-w-[300px] border-amber-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="agent"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="agent-in" id="agent-in"
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background" className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
/> />
<Handle <CanvasHandle
nodeId={id}
nodeType="agent"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background" className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Position, type Node, type NodeProps } from "@xyflow/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import CanvasHandle from "@/components/canvas/canvas-handle";
type AgentOutputNodeData = { type AgentOutputNodeData = {
isSkeleton?: boolean; isSkeleton?: boolean;
@@ -186,7 +187,7 @@ function partitionSections(
}; };
} }
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) { export default function AgentOutputNode({ id, data, selected }: NodeProps<AgentOutputNodeType>) {
const t = useTranslations("agentOutputNode"); const t = useTranslations("agentOutputNode");
const nodeData = data as AgentOutputNodeData; const nodeData = data as AgentOutputNodeData;
const isSkeleton = nodeData.isSkeleton === true; const isSkeleton = nodeData.isSkeleton === true;
@@ -240,7 +241,9 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
statusMessage={nodeData._statusMessage} statusMessage={nodeData._statusMessage}
className={`min-w-[300px] border-amber-500/30 ${isSkeleton ? "opacity-80" : ""}`} className={`min-w-[300px] border-amber-500/30 ${isSkeleton ? "opacity-80" : ""}`}
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="agent-output"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="agent-output-in" id="agent-output-in"

View File

@@ -3,7 +3,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react"; import { Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
@@ -30,6 +30,7 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import CanvasHandle from "@/components/canvas/canvas-handle";
type AiImageNodeData = { type AiImageNodeData = {
storageId?: string; storageId?: string;
@@ -194,7 +195,9 @@ export default function AiImageNode({
]} ]}
className="flex h-full w-full min-h-0 min-w-0 flex-col" className="flex h-full w-full min-h-0 min-w-0 flex-col"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="ai-image"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="prompt-in" id="prompt-in"
@@ -331,7 +334,9 @@ export default function AiImageNode({
</div> </div>
)} )}
<Handle <CanvasHandle
nodeId={id}
nodeType="ai-image"
type="source" type="source"
position={Position.Right} position={Position.Right}
id="image-out" id="image-out"

View File

@@ -5,7 +5,7 @@ import { useAction } from "convex/react";
import type { FunctionReference } from "convex/server"; import type { FunctionReference } from "convex/server";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { AlertCircle, Download, Loader2, RefreshCw, Video } from "lucide-react"; import { AlertCircle, Download, Loader2, RefreshCw, Video } from "lucide-react";
import { Handle, Position, useReactFlow, type Node, type NodeProps } from "@xyflow/react"; import { Position, useReactFlow, type Node, type NodeProps } from "@xyflow/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
@@ -14,6 +14,7 @@ import { classifyError } from "@/lib/ai-errors";
import { getVideoModel, type VideoModelDurationSeconds } from "@/lib/ai-video-models"; import { getVideoModel, type VideoModelDurationSeconds } from "@/lib/ai-video-models";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import CanvasHandle from "@/components/canvas/canvas-handle";
type AiVideoNodeData = { type AiVideoNodeData = {
prompt?: string; prompt?: string;
@@ -160,7 +161,9 @@ export default function AiVideoNode({ id, data, selected }: NodeProps<AiVideoNod
statusMessage={nodeData._statusMessage} statusMessage={nodeData._statusMessage}
className="flex h-full w-full min-h-0 min-w-0 flex-col" className="flex h-full w-full min-h-0 min-w-0 flex-col"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="ai-video"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="video-in" id="video-in"
@@ -240,7 +243,9 @@ export default function AiVideoNode({ id, data, selected }: NodeProps<AiVideoNod
) : null} ) : null}
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="ai-video"
type="source" type="source"
position={Position.Right} position={Position.Right}
id="video-out" id="video-out"

View File

@@ -8,7 +8,7 @@ import {
useState, useState,
type MouseEvent, type MouseEvent,
} from "react"; } from "react";
import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react"; import { Position, useStore, type Node, type NodeProps } from "@xyflow/react";
import { ExternalLink, ImageIcon } from "lucide-react"; import { ExternalLink, ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { import {
@@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import CanvasHandle from "@/components/canvas/canvas-handle";
type AssetNodeData = { type AssetNodeData = {
assetId?: number; assetId?: number;
@@ -152,7 +153,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
status={data._status} status={data._status}
statusMessage={data._statusMessage} statusMessage={data._statusMessage}
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="asset"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="h-3! w-3! border-2! border-background! bg-primary!" className="h-3! w-3! border-2! border-background! bg-primary!"
@@ -273,7 +276,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
/> />
) : null} ) : null}
<Handle <CanvasHandle
nodeId={id}
nodeType="asset"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="h-3! w-3! border-2! border-background! bg-primary!" className="h-3! w-3! border-2! border-background! bg-primary!"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Position, type Node, type NodeProps } from "@xyflow/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Palette } from "lucide-react"; import { Palette } from "lucide-react";
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import { COLOR_PRESETS } from "@/lib/image-pipeline/presets"; import { COLOR_PRESETS } from "@/lib/image-pipeline/presets";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import CanvasHandle from "@/components/canvas/canvas-handle";
type ColorAdjustNodeData = ColorAdjustData & { type ColorAdjustNodeData = ColorAdjustData & {
_status?: string; _status?: string;
@@ -191,7 +192,9 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
statusMessage={data._statusMessage} statusMessage={data._statusMessage}
className="min-w-[300px] border-cyan-500/30" className="min-w-[300px] border-cyan-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="color-adjust"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500" className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"
@@ -268,7 +271,9 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
/> />
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="color-adjust"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500" className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react"; import { Position, type NodeProps } from "@xyflow/react";
import { ImageIcon } from "lucide-react"; import { ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import CompareSurface from "./compare-surface"; import CompareSurface from "./compare-surface";
@@ -15,6 +15,7 @@ import {
resolveMixerPreviewFromGraph, resolveMixerPreviewFromGraph,
type MixerPreviewState, type MixerPreviewState,
} from "@/lib/canvas-mixer-preview"; } from "@/lib/canvas-mixer-preview";
import CanvasHandle from "@/components/canvas/canvas-handle";
interface CompareNodeData { interface CompareNodeData {
leftUrl?: string; leftUrl?: string;
@@ -242,21 +243,27 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
return ( return (
<BaseNodeWrapper nodeType="compare" selected={selected} className="p-0"> <BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
<Handle <CanvasHandle
nodeId={id}
nodeType="compare"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="left" id="left"
style={{ top: "35%" }} style={{ top: "35%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-blue-500" className="!h-3 !w-3 !border-2 !border-background !bg-blue-500"
/> />
<Handle <CanvasHandle
nodeId={id}
nodeType="compare"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="right" id="right"
style={{ top: "55%" }} style={{ top: "55%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500" className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
/> />
<Handle <CanvasHandle
nodeId={id}
nodeType="compare"
type="source" type="source"
position={Position.Right} position={Position.Right}
id="compare-out" id="compare-out"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react"; import { useCallback, useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Position, type Node, type NodeProps } from "@xyflow/react";
import { Crop } from "lucide-react"; import { Crop } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -24,6 +24,7 @@ import {
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import CanvasHandle from "@/components/canvas/canvas-handle";
type CropNodeViewData = CropNodeData & { type CropNodeViewData = CropNodeData & {
_status?: string; _status?: string;
@@ -400,7 +401,9 @@ export default function CropNode({ id, data, selected, width }: NodeProps<CropNo
statusMessage={data._statusMessage} statusMessage={data._statusMessage}
className="min-w-[320px] border-violet-500/30" className="min-w-[320px] border-violet-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="crop"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500" className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
@@ -735,7 +738,9 @@ export default function CropNode({ id, data, selected, width }: NodeProps<CropNo
{error ? <p className="text-[11px] text-destructive">{error}</p> : null} {error ? <p className="text-[11px] text-destructive">{error}</p> : null}
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="crop"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500" className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Position, type Node, type NodeProps } from "@xyflow/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { TrendingUp } from "lucide-react"; import { TrendingUp } from "lucide-react";
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import { CURVE_PRESETS } from "@/lib/image-pipeline/presets"; import { CURVE_PRESETS } from "@/lib/image-pipeline/presets";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import CanvasHandle from "@/components/canvas/canvas-handle";
type CurvesNodeData = CurvesData & { type CurvesNodeData = CurvesData & {
_status?: string; _status?: string;
@@ -163,7 +164,9 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
statusMessage={data._statusMessage} statusMessage={data._statusMessage}
className="min-w-[300px] border-emerald-500/30" className="min-w-[300px] border-emerald-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="curves"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500" className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
@@ -237,7 +240,9 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
/> />
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="curves"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500" className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Position, type Node, type NodeProps } from "@xyflow/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Focus } from "lucide-react"; import { Focus } from "lucide-react";
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import { DETAIL_PRESETS } from "@/lib/image-pipeline/presets"; import { DETAIL_PRESETS } from "@/lib/image-pipeline/presets";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import CanvasHandle from "@/components/canvas/canvas-handle";
type DetailAdjustNodeData = DetailAdjustData & { type DetailAdjustNodeData = DetailAdjustData & {
_status?: string; _status?: string;
@@ -202,7 +203,9 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
statusMessage={data._statusMessage} statusMessage={data._statusMessage}
className="min-w-[300px] border-indigo-500/30" className="min-w-[300px] border-indigo-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="detail-adjust"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500" className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"
@@ -286,7 +289,9 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
/> />
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="detail-adjust"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500" className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react"; import { Position, type NodeProps } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Download, Loader2 } from "lucide-react"; import { Download, Loader2 } from "lucide-react";
@@ -11,6 +11,7 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import CanvasHandle from "@/components/canvas/canvas-handle";
interface FrameNodeData { interface FrameNodeData {
label?: string; label?: string;
@@ -125,13 +126,17 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
<div className="nodrag h-full w-full" /> <div className="nodrag h-full w-full" />
<Handle <CanvasHandle
nodeId={id}
nodeType="frame"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="frame-in" id="frame-in"
className="!h-3 !w-3 !border-2 !border-background !bg-orange-500" className="!h-3 !w-3 !border-2 !border-background !bg-orange-500"
/> />
<Handle <CanvasHandle
nodeId={id}
nodeType="frame"
type="source" type="source"
position={Position.Right} position={Position.Right}
id="frame-out" id="frame-out"

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; import { Position, type NodeProps, type Node } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import CanvasHandle from "@/components/canvas/canvas-handle";
type GroupNodeData = { type GroupNodeData = {
label?: string; label?: string;
@@ -47,7 +48,9 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
selected={selected} selected={selected}
className="min-w-[200px] min-h-[150px] p-3 border-dashed" className="min-w-[200px] min-h-[150px] p-3 border-dashed"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="group"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background" className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
@@ -71,7 +74,9 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
</div> </div>
)} )}
<Handle <CanvasHandle
nodeId={id}
nodeType="group"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background" className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"

View File

@@ -8,7 +8,7 @@ import {
type ChangeEvent, type ChangeEvent,
type DragEvent, type DragEvent,
} from "react"; } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; import { Position, type NodeProps, type Node } from "@xyflow/react";
import { Maximize2, X } from "lucide-react"; import { Maximize2, X } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
@@ -37,6 +37,7 @@ import {
getImageDimensions, getImageDimensions,
} from "@/components/canvas/canvas-media-utils"; } from "@/components/canvas/canvas-media-utils";
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import CanvasHandle from "@/components/canvas/canvas-handle";
const ALLOWED_IMAGE_TYPES = new Set([ const ALLOWED_IMAGE_TYPES = new Set([
"image/png", "image/png",
@@ -508,7 +509,9 @@ export default function ImageNode({
}, },
]} ]}
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="image"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="h-3! w-3! bg-primary! border-2! border-background!" className="h-3! w-3! bg-primary! border-2! border-background!"
@@ -609,7 +612,9 @@ export default function ImageNode({
className="hidden" className="hidden"
/> />
<Handle <CanvasHandle
nodeId={id}
nodeType="image"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="h-3! w-3! bg-primary! border-2! border-background!" className="h-3! w-3! bg-primary! border-2! border-background!"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Position, type Node, type NodeProps } from "@xyflow/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Sun } from "lucide-react"; import { Sun } from "lucide-react";
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import { LIGHT_PRESETS } from "@/lib/image-pipeline/presets"; import { LIGHT_PRESETS } from "@/lib/image-pipeline/presets";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import CanvasHandle from "@/components/canvas/canvas-handle";
type LightAdjustNodeData = LightAdjustData & { type LightAdjustNodeData = LightAdjustData & {
_status?: string; _status?: string;
@@ -213,7 +214,9 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
statusMessage={data._statusMessage} statusMessage={data._statusMessage}
className="min-w-[300px] border-amber-500/30" className="min-w-[300px] border-amber-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="light-adjust"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500" className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"
@@ -292,7 +295,9 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
/> />
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="light-adjust"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500" className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react"; import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react"; import { Position, type NodeProps } from "@xyflow/react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
@@ -12,6 +12,7 @@ import {
type MixerBlendMode, type MixerBlendMode,
} from "@/lib/canvas-mixer-preview"; } from "@/lib/canvas-mixer-preview";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import CanvasHandle from "@/components/canvas/canvas-handle";
const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"]; const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"];
@@ -56,21 +57,27 @@ export default function MixerNode({ id, data, selected }: NodeProps) {
return ( return (
<BaseNodeWrapper nodeType="mixer" selected={selected} className="p-0"> <BaseNodeWrapper nodeType="mixer" selected={selected} className="p-0">
<Handle <CanvasHandle
nodeId={id}
nodeType="mixer"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="base" id="base"
style={{ top: "35%" }} style={{ top: "35%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500" className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
/> />
<Handle <CanvasHandle
nodeId={id}
nodeType="mixer"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="overlay" id="overlay"
style={{ top: "58%" }} style={{ top: "58%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-pink-500" className="!h-3 !w-3 !border-2 !border-background !bg-pink-500"
/> />
<Handle <CanvasHandle
nodeId={id}
nodeType="mixer"
type="source" type="source"
position={Position.Right} position={Position.Right}
id="mixer-out" id="mixer-out"

View File

@@ -1,11 +1,12 @@
"use client"; "use client";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; import { Position, type NodeProps, type Node } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import CanvasHandle from "@/components/canvas/canvas-handle";
type NoteNodeData = { type NoteNodeData = {
content?: string; content?: string;
@@ -53,7 +54,9 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
return ( return (
<BaseNodeWrapper nodeType="note" selected={selected} className="p-3"> <BaseNodeWrapper nodeType="note" selected={selected} className="p-3">
<Handle <CanvasHandle
nodeId={id}
nodeType="note"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !bg-primary !border-2 !border-background" className="!h-3 !w-3 !bg-primary !border-2 !border-background"
@@ -85,7 +88,9 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
</div> </div>
)} )}
<Handle <CanvasHandle
nodeId={id}
nodeType="note"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !bg-primary !border-2 !border-background" className="!h-3 !w-3 !bg-primary !border-2 !border-background"

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Handle,
Position, Position,
useReactFlow, useReactFlow,
useStore, useStore,
@@ -45,6 +44,7 @@ import { useRouter } from "next/navigation";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { classifyError } from "@/lib/ai-errors"; import { classifyError } from "@/lib/ai-errors";
import { normalizePublicTier } from "@/lib/tier-credits"; import { normalizePublicTier } from "@/lib/tier-credits";
import CanvasHandle from "@/components/canvas/canvas-handle";
type PromptNodeData = { type PromptNodeData = {
prompt?: string; prompt?: string;
@@ -353,7 +353,9 @@ export default function PromptNode({
statusMessage={nodeData._statusMessage} statusMessage={nodeData._statusMessage}
className="min-w-[240px] border-violet-500/30" className="min-w-[240px] border-violet-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="prompt"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="image-in" id="image-in"
@@ -489,7 +491,9 @@ export default function PromptNode({
</div> </div>
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="prompt"
type="source" type="source"
position={Position.Right} position={Position.Right}
id="prompt-out" id="prompt-out"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Position, type Node, type NodeProps } from "@xyflow/react";
import { AlertCircle, ArrowDown, CheckCircle2, CloudUpload, Loader2, Maximize2, X } from "lucide-react"; import { AlertCircle, ArrowDown, CheckCircle2, CloudUpload, Loader2, Maximize2, X } from "lucide-react";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
@@ -29,6 +29,7 @@ import {
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import CanvasHandle from "@/components/canvas/canvas-handle";
type RenderResolutionOption = "original" | "2x" | "custom"; type RenderResolutionOption = "original" | "2x" | "custom";
type RenderFormatOption = "png" | "jpeg" | "webp"; type RenderFormatOption = "png" | "jpeg" | "webp";
@@ -978,7 +979,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
]} ]}
className="flex h-full min-w-[280px] flex-col overflow-hidden border-sky-500/30" className="flex h-full min-w-[280px] flex-col overflow-hidden border-sky-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="render"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500" className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
@@ -1273,7 +1276,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
</div> </div>
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="render"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500" className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"

View File

@@ -2,7 +2,6 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { import {
Handle,
Position, Position,
useReactFlow, useReactFlow,
type NodeProps, type NodeProps,
@@ -20,6 +19,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import CanvasHandle from "@/components/canvas/canvas-handle";
type TextNodeData = { type TextNodeData = {
content?: string; content?: string;
@@ -155,7 +155,9 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
]} ]}
className="relative" className="relative"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="text"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-3 !w-3 !bg-primary !border-2 !border-background" className="!h-3 !w-3 !bg-primary !border-2 !border-background"
@@ -190,7 +192,9 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
</div> </div>
)} )}
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="text"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-3 !w-3 !bg-primary !border-2 !border-background" className="!h-3 !w-3 !bg-primary !border-2 !border-background"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Handle, Position, useStore, type NodeProps } from "@xyflow/react"; import { Position, useStore, type NodeProps } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { Play } from "lucide-react"; import { Play } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
@@ -12,6 +12,7 @@ import {
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import CanvasHandle from "@/components/canvas/canvas-handle";
type VideoNodeData = { type VideoNodeData = {
canvasId?: string; canvasId?: string;
@@ -150,7 +151,9 @@ export default function VideoNode({
return ( return (
<BaseNodeWrapper nodeType="video" selected={selected}> <BaseNodeWrapper nodeType="video" selected={selected}>
<Handle <CanvasHandle
nodeId={id}
nodeType="video"
type="target" type="target"
position={Position.Left} position={Position.Left}
className="h-3! w-3! border-2! border-background! bg-primary!" className="h-3! w-3! border-2! border-background! bg-primary!"
@@ -245,7 +248,9 @@ export default function VideoNode({
/> />
) : null} ) : null}
<Handle <CanvasHandle
nodeId={id}
nodeType="video"
type="source" type="source"
position={Position.Right} position={Position.Right}
className="h-3! w-3! border-2! border-background! bg-primary!" className="h-3! w-3! border-2! border-background! bg-primary!"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Handle, Position, useReactFlow, useStore, type Node, type NodeProps } from "@xyflow/react"; import { Position, useReactFlow, useStore, type Node, type NodeProps } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import type { FunctionReference } from "convex/server"; import type { FunctionReference } from "convex/server";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -33,6 +33,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import CanvasHandle from "@/components/canvas/canvas-handle";
type VideoPromptNodeData = { type VideoPromptNodeData = {
prompt?: string; prompt?: string;
@@ -300,7 +301,9 @@ export default function VideoPromptNode({
statusMessage={nodeData._statusMessage} statusMessage={nodeData._statusMessage}
className="min-w-[260px] border-violet-500/30" className="min-w-[260px] border-violet-500/30"
> >
<Handle <CanvasHandle
nodeId={id}
nodeType="video-prompt"
type="target" type="target"
position={Position.Left} position={Position.Left}
id="video-prompt-in" id="video-prompt-in"
@@ -407,7 +410,9 @@ export default function VideoPromptNode({
) : null} ) : null}
</div> </div>
<Handle <CanvasHandle
nodeId={id}
nodeType="video-prompt"
type="source" type="source"
position={Position.Right} position={Position.Right}
id="video-prompt-out" id="video-prompt-out"

View File

@@ -10,6 +10,10 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import type { CanvasNodeType } from "@/lib/canvas-node-types"; import type { CanvasNodeType } from "@/lib/canvas-node-types";
import {
resolveCanvasMagnetTarget,
type CanvasMagnetTarget,
} from "./canvas-connection-magnetism";
import { import {
getConnectEndClientPoint, getConnectEndClientPoint,
hasHandleKey, hasHandleKey,
@@ -24,6 +28,7 @@ import {
validateCanvasConnectionByType, validateCanvasConnectionByType,
validateCanvasEdgeSplit, validateCanvasEdgeSplit,
} from "./canvas-connection-validation"; } from "./canvas-connection-validation";
import { useCanvasConnectionMagnetism } from "./canvas-connection-magnetism-context";
import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasReconnectHandlers } from "./canvas-reconnect";
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu"; import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
@@ -122,6 +127,7 @@ export function useCanvasConnections({
runSwapMixerInputsMutation, runSwapMixerInputsMutation,
showConnectionRejectedToast, showConnectionRejectedToast,
}: UseCanvasConnectionsParams) { }: UseCanvasConnectionsParams) {
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const [connectionDropMenu, setConnectionDropMenu] = const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null); useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null); const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
@@ -133,17 +139,40 @@ export function useCanvasConnections({
}, [connectionDropMenu]); }, [connectionDropMenu]);
const onConnectStart = useCallback<OnConnectStart>((_event, params) => { const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
setActiveTarget(null);
isConnectDragActiveRef.current = true; isConnectDragActiveRef.current = true;
logCanvasConnectionDebug("connect:start", { logCanvasConnectionDebug("connect:start", {
nodeId: params.nodeId, nodeId: params.nodeId,
handleId: params.handleId, handleId: params.handleId,
handleType: params.handleType, handleType: params.handleType,
}); });
}, []); }, [setActiveTarget]);
const toDroppedConnectionFromMagnetTarget = useCallback(
(fromHandleType: "source" | "target", fromNodeId: string, fromHandleId: string | undefined, magnetTarget: CanvasMagnetTarget) => {
if (fromHandleType === "source") {
return {
sourceNodeId: fromNodeId,
targetNodeId: magnetTarget.nodeId,
sourceHandle: fromHandleId,
targetHandle: magnetTarget.handleId,
};
}
return {
sourceNodeId: magnetTarget.nodeId,
targetNodeId: fromNodeId,
sourceHandle: magnetTarget.handleId,
targetHandle: fromHandleId,
};
},
[],
);
const onConnect = useCallback( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
isConnectDragActiveRef.current = false; isConnectDragActiveRef.current = false;
try {
const validationError = validateCanvasConnection(connection, nodes, edges); const validationError = validateCanvasConnection(connection, nodes, edges);
if (validationError) { if (validationError) {
logCanvasConnectionDebug("connect:invalid-direct", { logCanvasConnectionDebug("connect:invalid-direct", {
@@ -181,8 +210,11 @@ export function useCanvasConnections({
sourceHandle: connection.sourceHandle ?? undefined, sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined, targetHandle: connection.targetHandle ?? undefined,
}); });
} finally {
setActiveTarget(null);
}
}, },
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast], [canvasId, edges, nodes, runCreateEdgeMutation, setActiveTarget, showConnectionRejectedToast],
); );
const resolveMixerSwapReconnect = useCallback( const resolveMixerSwapReconnect = useCallback(
@@ -252,6 +284,7 @@ export function useCanvasConnections({
const onConnectEnd = useCallback<OnConnectEnd>( const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => { (event, connectionState) => {
if (!isConnectDragActiveRef.current) { if (!isConnectDragActiveRef.current) {
setActiveTarget(null);
logCanvasConnectionDebug("connect:end-ignored", { logCanvasConnectionDebug("connect:end-ignored", {
reason: "drag-not-active", reason: "drag-not-active",
isValid: connectionState.isValid ?? null, isValid: connectionState.isValid ?? null,
@@ -264,6 +297,7 @@ export function useCanvasConnections({
} }
isConnectDragActiveRef.current = false; isConnectDragActiveRef.current = false;
try {
if (isReconnectDragActiveRef.current) { if (isReconnectDragActiveRef.current) {
logCanvasConnectionDebug("connect:end-ignored", { logCanvasConnectionDebug("connect:end-ignored", {
reason: "reconnect-active", reason: "reconnect-active",
@@ -319,7 +353,7 @@ export function useCanvasConnections({
}); });
const flow = screenToFlowPosition({ x: pt.x, y: pt.y }); const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
const droppedConnection = resolveDroppedConnectionTarget({ let droppedConnection = resolveDroppedConnectionTarget({
point: pt, point: pt,
fromNodeId: fromNode.id, fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? undefined, fromHandleId: fromHandle.id ?? undefined,
@@ -328,6 +362,28 @@ export function useCanvasConnections({
edges: edgesRef.current, edges: edgesRef.current,
}); });
if (!droppedConnection) {
const fallbackMagnetTarget =
activeTarget ??
resolveCanvasMagnetTarget({
point: pt,
fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? undefined,
fromHandleType: fromHandle.type,
nodes: nodesRef.current,
edges: edgesRef.current,
});
if (fallbackMagnetTarget) {
droppedConnection = toDroppedConnectionFromMagnetTarget(
fromHandle.type,
fromNode.id,
fromHandle.id ?? undefined,
fallbackMagnetTarget,
);
}
}
logCanvasConnectionDebug("connect:end-drop-result", { logCanvasConnectionDebug("connect:end-drop-result", {
point: pt, point: pt,
flow, flow,
@@ -445,6 +501,9 @@ export function useCanvasConnections({
fromHandleId: fromHandle.id ?? undefined, fromHandleId: fromHandle.id ?? undefined,
fromHandleType: fromHandle.type, fromHandleType: fromHandle.type,
}); });
} finally {
setActiveTarget(null);
}
}, },
[ [
canvasId, canvasId,
@@ -454,7 +513,10 @@ export function useCanvasConnections({
runCreateEdgeMutation, runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation, runSplitEdgeAtExistingNodeMutation,
screenToFlowPosition, screenToFlowPosition,
setActiveTarget,
showConnectionRejectedToast, showConnectionRejectedToast,
activeTarget,
toDroppedConnectionFromMagnetTarget,
], ],
); );
@@ -598,6 +660,9 @@ export function useCanvasConnections({
onInvalidConnection: (reason) => { onInvalidConnection: (reason) => {
showConnectionRejectedToast(reason as CanvasConnectionValidationReason); showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
}, },
clearActiveMagnetTarget: () => {
setActiveTarget(null);
},
}); });
return { return {

View File

@@ -68,6 +68,7 @@ Alle Adapter-Funktionen zwischen Convex-Datenmodell und React Flow. Details in `
- `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ (inkl. `video-prompt` und `ai-video`) - `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ (inkl. `video-prompt` und `ai-video`)
- `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ (inkl. `video-prompt-out/in` und `video-out/in`) - `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ (inkl. `video-prompt-out/in` und `video-out/in`)
- `SOURCE_NODE_GLOW_RGB` — Edge-Glow-Farben pro Source-Node-Typ (inkl. `video-prompt` und `ai-video`) - `SOURCE_NODE_GLOW_RGB` — Edge-Glow-Farben pro Source-Node-Typ (inkl. `video-prompt` und `ai-video`)
- `canvasHandleAccentRgb`, `canvasHandleAccentColor`, `canvasHandleAccentColorWithAlpha` — gemeinsame Handle-Akzentfarben (inkl. Spezialfälle für `compare.left/right/compare-out` und `mixer.base/overlay/mixer-out`)
- `agent` ist als input-only Node enthalten (`NODE_HANDLE_MAP.agent = { target: "agent-in" }`) - `agent` ist als input-only Node enthalten (`NODE_HANDLE_MAP.agent = { target: "agent-in" }`)
- `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung - `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung
- `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen - `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen

View File

@@ -102,7 +102,9 @@ export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
* Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente). * Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente).
* Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad). * Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad).
*/ */
const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> = { type RgbColor = readonly [number, number, number];
const SOURCE_NODE_GLOW_RGB: Record<string, RgbColor> = {
prompt: [139, 92, 246], prompt: [139, 92, 246],
"video-prompt": [124, 58, 237], "video-prompt": [124, 58, 237],
"ai-image": [139, 92, 246], "ai-image": [139, 92, 246],
@@ -123,21 +125,158 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
render: [14, 165, 233], render: [14, 165, 233],
agent: [245, 158, 11], agent: [245, 158, 11],
"agent-output": [245, 158, 11], "agent-output": [245, 158, 11],
mixer: [100, 116, 139],
}; };
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */ /** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
const COMPARE_HANDLE_CONNECTION_RGB: Record< const COMPARE_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
string,
readonly [number, number, number]
> = {
left: [59, 130, 246], left: [59, 130, 246],
right: [16, 185, 129], right: [16, 185, 129],
"compare-out": [100, 116, 139], "compare-out": [100, 116, 139],
}; };
const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [ const MIXER_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
13, 148, 136, base: [14, 165, 233],
]; overlay: [236, 72, 153],
"mixer-out": [100, 116, 139],
};
const CONNECTION_LINE_FALLBACK_RGB: RgbColor = [13, 148, 136];
export function canvasHandleAccentRgb(args: {
nodeType: string | undefined;
handleId?: string | null;
handleType: "source" | "target";
}): RgbColor {
const nodeType = args.nodeType;
const handleId = args.handleId ?? undefined;
const handleType = args.handleType;
if (nodeType === "compare" && handleId) {
if (handleType === "target" && handleId === "compare-out") {
return SOURCE_NODE_GLOW_RGB.compare;
}
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
if (byHandle) {
return byHandle;
}
}
if (nodeType === "mixer" && handleId) {
if (handleType === "target" && handleId === "mixer-out") {
return SOURCE_NODE_GLOW_RGB.mixer;
}
const byHandle = MIXER_HANDLE_CONNECTION_RGB[handleId];
if (byHandle) {
return byHandle;
}
}
if (!nodeType) {
return CONNECTION_LINE_FALLBACK_RGB;
}
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
}
export function canvasHandleAccentColor(args: {
nodeType: string | undefined;
handleId?: string | null;
handleType: "source" | "target";
}): string {
const [r, g, b] = canvasHandleAccentRgb(args);
return `rgb(${r}, ${g}, ${b})`;
}
export function canvasHandleAccentColorWithAlpha(
args: {
nodeType: string | undefined;
handleId?: string | null;
handleType: "source" | "target";
},
alpha: number,
): string {
const [r, g, b] = canvasHandleAccentRgb(args);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function clampUnit(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
if (value <= 0) {
return 0;
}
if (value >= 1) {
return 1;
}
return value;
}
function lerp(min: number, max: number, t: number): number {
return min + (max - min) * t;
}
export function canvasHandleGlowShadow(args: {
nodeType: string | undefined;
handleId?: string | null;
handleType: "source" | "target";
strength: number;
colorMode: EdgeGlowColorMode;
}): string | undefined {
const strength = clampUnit(args.strength);
if (strength <= 0) {
return undefined;
}
const [r, g, b] = canvasHandleAccentRgb(args);
const isDark = args.colorMode === "dark";
const ringAlpha = isDark
? lerp(0.08, 0.3, strength)
: lerp(0.06, 0.2, strength);
const glowAlpha = isDark
? lerp(0.12, 0.58, strength)
: lerp(0.08, 0.34, strength);
const ringSize = isDark
? lerp(1.8, 6.4, strength)
: lerp(1.5, 5.2, strength);
const glowSize = isDark
? lerp(4.5, 15, strength)
: lerp(3.5, 12, strength);
return `0 0 0 ${ringSize.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${ringAlpha.toFixed(3)}), 0 0 ${glowSize.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${glowAlpha.toFixed(3)})`;
}
export function connectionLineGlowFilter(args: {
nodeType: string | undefined;
handleId: string | null | undefined;
strength: number;
colorMode: EdgeGlowColorMode;
}): string | undefined {
const strength = clampUnit(args.strength);
if (strength <= 0) {
return undefined;
}
const [r, g, b] = connectionLineAccentRgb(args.nodeType, args.handleId);
const isDark = args.colorMode === "dark";
const innerAlpha = isDark
? lerp(0.22, 0.72, strength)
: lerp(0.12, 0.42, strength);
const outerAlpha = isDark
? lerp(0.12, 0.38, strength)
: lerp(0.06, 0.2, strength);
const innerBlur = isDark
? lerp(2.4, 4.2, strength)
: lerp(2, 3.4, strength);
const outerBlur = isDark
? lerp(5.4, 9.8, strength)
: lerp(4.6, 7.8, strength);
return `drop-shadow(0 0 ${innerBlur.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${innerAlpha.toFixed(3)})) drop-shadow(0 0 ${outerBlur.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${outerAlpha.toFixed(3)}))`;
}
/** /**
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect). * RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
@@ -145,13 +284,12 @@ const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
export function connectionLineAccentRgb( export function connectionLineAccentRgb(
nodeType: string | undefined, nodeType: string | undefined,
handleId: string | null | undefined, handleId: string | null | undefined,
): readonly [number, number, number] { ): RgbColor {
if (nodeType === "compare" && handleId) { return canvasHandleAccentRgb({
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId]; nodeType,
if (byHandle) return byHandle; handleId,
} handleType: "source",
if (!nodeType) return CONNECTION_LINE_FALLBACK_RGB; });
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
} }
export type EdgeGlowColorMode = "light" | "dark"; export type EdgeGlowColorMode = "light" | "dark";

View File

@@ -142,6 +142,7 @@ vi.mock("next-intl", () => ({
vi.mock("@xyflow/react", () => ({ vi.mock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
import AgentNode from "@/components/canvas/nodes/agent-node"; import AgentNode from "@/components/canvas/nodes/agent-node";

View File

@@ -60,6 +60,7 @@ vi.mock("@xyflow/react", () => ({
}); });
}, },
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
const translations: Record<string, string> = { const translations: Record<string, string> = {

View File

@@ -20,6 +20,7 @@ vi.mock("@xyflow/react", () => ({
}); });
}, },
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
const translations: Record<string, string> = { const translations: Record<string, string> = {

View File

@@ -54,6 +54,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
vi.mock("@xyflow/react", () => ({ vi.mock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
useReactFlow: () => ({ useReactFlow: () => ({
getEdges: mocks.getEdges, getEdges: mocks.getEdges,
getNode: mocks.getNode, getNode: mocks.getNode,

View File

@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@xyflow/react", () => ({ vi.mock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
vi.mock("next-intl", () => ({ vi.mock("next-intl", () => ({

View File

@@ -19,6 +19,7 @@ const parameterSliderState = vi.hoisted(() => ({
vi.mock("@xyflow/react", () => ({ vi.mock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
vi.mock("convex/react", () => ({ vi.mock("convex/react", () => ({

View File

@@ -135,6 +135,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
vi.mock("@xyflow/react", () => ({ vi.mock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) => useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
selector({ edges: mocks.edges, nodes: mocks.nodes }), selector({ edges: mocks.edges, nodes: mocks.nodes }),
useReactFlow: () => ({ useReactFlow: () => ({

View File

@@ -691,6 +691,7 @@ describe("preview histogram call sites", () => {
vi.doMock("@xyflow/react", () => ({ vi.doMock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
vi.doMock("convex/react", () => ({ vi.doMock("convex/react", () => ({
useMutation: () => vi.fn(async () => undefined), useMutation: () => vi.fn(async () => undefined),
@@ -754,6 +755,8 @@ describe("preview histogram call sites", () => {
})); }));
vi.doMock("@/lib/canvas-utils", () => ({ vi.doMock("@/lib/canvas-utils", () => ({
resolveMediaAspectRatio: () => null, resolveMediaAspectRatio: () => null,
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
})); }));
vi.doMock("@/lib/image-formats", () => ({ vi.doMock("@/lib/image-formats", () => ({
parseAspectRatioString: () => ({ w: 1, h: 1 }), parseAspectRatioString: () => ({ w: 1, h: 1 }),
@@ -875,6 +878,7 @@ describe("preview histogram call sites", () => {
vi.doMock("@xyflow/react", () => ({ vi.doMock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
vi.doMock("convex/react", () => ({ vi.doMock("convex/react", () => ({
useMutation: () => vi.fn(async () => undefined), useMutation: () => vi.fn(async () => undefined),
@@ -935,6 +939,8 @@ describe("preview histogram call sites", () => {
})); }));
vi.doMock("@/lib/canvas-utils", () => ({ vi.doMock("@/lib/canvas-utils", () => ({
resolveMediaAspectRatio: () => null, resolveMediaAspectRatio: () => null,
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
})); }));
vi.doMock("@/lib/image-formats", () => ({ vi.doMock("@/lib/image-formats", () => ({
parseAspectRatioString: () => ({ w: 1, h: 1 }), parseAspectRatioString: () => ({ w: 1, h: 1 }),
@@ -1063,6 +1069,7 @@ describe("preview histogram call sites", () => {
vi.doMock("@xyflow/react", () => ({ vi.doMock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
})); }));
vi.doMock("convex/react", () => ({ vi.doMock("convex/react", () => ({
useMutation: () => vi.fn(async () => undefined), useMutation: () => vi.fn(async () => undefined),
@@ -1126,6 +1133,8 @@ describe("preview histogram call sites", () => {
})); }));
vi.doMock("@/lib/canvas-utils", () => ({ vi.doMock("@/lib/canvas-utils", () => ({
resolveMediaAspectRatio: () => null, resolveMediaAspectRatio: () => null,
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
})); }));
vi.doMock("@/lib/image-formats", () => ({ vi.doMock("@/lib/image-formats", () => ({
parseAspectRatioString: () => ({ w: 1, h: 1 }), parseAspectRatioString: () => ({ w: 1, h: 1 }),

View File

@@ -123,6 +123,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
vi.mock("@xyflow/react", () => ({ vi.mock("@xyflow/react", () => ({
Handle: () => null, Handle: () => null,
Position: { Left: "left", Right: "right" }, Position: { Left: "left", Right: "right" },
useConnection: () => ({ inProgress: false }),
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) => useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
selector({ edges: mocks.edges, nodes: mocks.nodes }), selector({ edges: mocks.edges, nodes: mocks.nodes }),
useReactFlow: () => ({ useReactFlow: () => ({

View File

@@ -25,6 +25,8 @@ export default defineConfig({
"components/canvas/__tests__/use-canvas-edge-types.test.tsx", "components/canvas/__tests__/use-canvas-edge-types.test.tsx",
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
"components/canvas/__tests__/canvas-delete-handlers.test.tsx", "components/canvas/__tests__/canvas-delete-handlers.test.tsx",
"components/canvas/__tests__/canvas-handle.test.tsx",
"components/canvas/__tests__/custom-connection-line.test.tsx",
"components/canvas/__tests__/canvas-media-utils.test.ts", "components/canvas/__tests__/canvas-media-utils.test.ts",
"components/canvas/__tests__/base-node-wrapper.test.tsx", "components/canvas/__tests__/base-node-wrapper.test.tsx",
"components/canvas/__tests__/use-node-local-data.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx",