Merge branch 'feat/canvas-magnetism-20260411-082412'
This commit is contained in:
@@ -190,6 +190,11 @@
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Reconnect-Anker immer pointer-interactive halten (Drag-Detach/Reconnect) */
|
||||
.react-flow__edgeupdater {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Proximity-Vorschaukante (temp) */
|
||||
.react-flow__edge.temp {
|
||||
opacity: 0.9;
|
||||
|
||||
@@ -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-delete-handlers.ts` | Hook für `onBeforeDelete`, `onNodesDelete`, `onEdgesDelete` inkl. Bridge-Edges |
|
||||
| `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)` |
|
||||
| `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) |
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpe
|
||||
|
||||
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
||||
return {
|
||||
id: overrides.id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
...overrides,
|
||||
@@ -40,6 +39,34 @@ function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
|
||||
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", () => {
|
||||
afterEach(() => {
|
||||
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", () => {
|
||||
const droppedNode = createNode({
|
||||
id: "node-dropped",
|
||||
@@ -177,4 +367,44 @@ describe("resolveDroppedConnectionTarget", () => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
346
components/canvas/__tests__/canvas-handle.test.tsx
Normal file
346
components/canvas/__tests__/canvas-handle.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,31 @@ vi.mock("@xyflow/react", () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-handle", () => ({
|
||||
default: ({
|
||||
id,
|
||||
type,
|
||||
nodeId,
|
||||
nodeType,
|
||||
style,
|
||||
}: {
|
||||
id?: string;
|
||||
type: "source" | "target";
|
||||
nodeId: string;
|
||||
nodeType?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) => (
|
||||
<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", () => ({
|
||||
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%"');
|
||||
});
|
||||
});
|
||||
|
||||
310
components/canvas/__tests__/custom-connection-line.test.tsx
Normal file
310
components/canvas/__tests__/custom-connection-line.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,31 @@ vi.mock("@xyflow/react", () => ({
|
||||
Position: { Left: "left", Right: "right" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-handle", () => ({
|
||||
default: ({
|
||||
id,
|
||||
type,
|
||||
nodeId,
|
||||
nodeType,
|
||||
style,
|
||||
}: {
|
||||
id?: string;
|
||||
type: "source" | "target";
|
||||
nodeId: string;
|
||||
nodeType?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) => (
|
||||
<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", () => ({
|
||||
useCanvasSync: () => ({
|
||||
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
|
||||
@@ -222,8 +247,20 @@ describe("MixerNode", () => {
|
||||
it("renders expected mixer handles", async () => {
|
||||
await renderNode();
|
||||
|
||||
expect(container?.querySelector('[data-handle-id="base"][data-handle-type="target"]')).toBeTruthy();
|
||||
expect(container?.querySelector('[data-handle-id="overlay"][data-handle-type="target"]')).toBeTruthy();
|
||||
expect(container?.querySelector('[data-handle-id="mixer-out"][data-handle-type="source"]')).toBeTruthy();
|
||||
expect(
|
||||
container?.querySelector(
|
||||
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="base"][data-handle-type="target"][data-top="35%"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container?.querySelector(
|
||||
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="overlay"][data-handle-type="target"][data-top="58%"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container?.querySelector(
|
||||
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="mixer-out"][data-handle-type="source"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,11 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveDroppedConnectionTarget: vi.fn(),
|
||||
resolveCanvasMagnetTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
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 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 { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||
@@ -35,6 +52,14 @@ const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasConnections> | 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;
|
||||
|
||||
type HookHarnessProps = {
|
||||
@@ -47,9 +72,12 @@ type HookHarnessProps = {
|
||||
setEdgesMock?: ReturnType<typeof vi.fn>;
|
||||
nodes?: RFNode[];
|
||||
edges?: RFEdge[];
|
||||
initialMagnetTarget?: CanvasMagnetTarget | null;
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
type HookHarnessInnerProps = HookHarnessProps;
|
||||
|
||||
function HookHarnessInner({
|
||||
helperResult,
|
||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||
@@ -59,7 +87,10 @@ function HookHarness({
|
||||
setEdgesMock,
|
||||
nodes: providedNodes,
|
||||
edges: providedEdges,
|
||||
}: HookHarnessProps) {
|
||||
initialMagnetTarget,
|
||||
}: HookHarnessInnerProps) {
|
||||
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||
const didInitializeMagnetTargetRef = useRef(false);
|
||||
const [nodes] = useState<RFNode[]>(
|
||||
providedNodes ?? [
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
@@ -88,6 +119,17 @@ function HookHarness({
|
||||
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
||||
}, [helperResult]);
|
||||
|
||||
useEffect(() => {
|
||||
mocks.resolveCanvasMagnetTarget.mockReturnValue(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) {
|
||||
didInitializeMagnetTargetRef.current = true;
|
||||
setActiveTarget(initialMagnetTarget);
|
||||
}
|
||||
}, [initialMagnetTarget, setActiveTarget]);
|
||||
|
||||
const handlers = useCanvasConnections({
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes,
|
||||
@@ -115,15 +157,36 @@ function HookHarness({
|
||||
latestHandlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
useEffect(() => {
|
||||
latestMagnetTargetRef.current = activeTarget;
|
||||
}, [activeTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
latestSetActiveTargetRef.current = setActiveTarget;
|
||||
return () => {
|
||||
latestSetActiveTargetRef.current = null;
|
||||
};
|
||||
}, [setActiveTarget]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function HookHarness(props: HookHarnessProps) {
|
||||
return (
|
||||
<CanvasConnectionMagnetismProvider>
|
||||
<HookHarnessInner {...props} />
|
||||
</CanvasConnectionMagnetismProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useCanvasConnections", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
latestMagnetTargetRef.current = null;
|
||||
latestSetActiveTargetRef.current = null;
|
||||
vi.clearAllMocks();
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
@@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => {
|
||||
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -699,6 +699,7 @@ describe("favorite retention in strict local node flows", () => {
|
||||
vi.doMock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
|
||||
const importedModule = (await import(modulePath)) as {
|
||||
|
||||
51
components/canvas/canvas-connection-magnetism-context.tsx
Normal file
51
components/canvas/canvas-connection-magnetism-context.tsx
Normal 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;
|
||||
}
|
||||
244
components/canvas/canvas-connection-magnetism.ts
Normal file
244
components/canvas/canvas-connection-magnetism.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
146
components/canvas/canvas-handle.tsx
Normal file
146
components/canvas/canvas-handle.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getSourceImageFromGraph,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
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_EDGE_PREFIX = "optimistic_edge_";
|
||||
@@ -421,17 +422,7 @@ export function resolveDroppedConnectionTarget(args: {
|
||||
? []
|
||||
: document.elementsFromPoint(args.point.x, args.point.y);
|
||||
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
|
||||
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;
|
||||
}
|
||||
|
||||
if (nodeElement) {
|
||||
const targetNodeId = nodeElement.dataset.id;
|
||||
if (!targetNodeId) {
|
||||
logCanvasConnectionDebug("drop-target:node-missing-data-id", {
|
||||
@@ -508,6 +499,65 @@ export function resolveDroppedConnectionTarget(args: {
|
||||
resolvedConnection: 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ type UseCanvasReconnectHandlersParams = {
|
||||
nextOtherEdgeHandle: "base" | "overlay";
|
||||
} | null;
|
||||
onInvalidConnection?: (message: string) => void;
|
||||
clearActiveMagnetTarget?: () => void;
|
||||
};
|
||||
|
||||
export function useCanvasReconnectHandlers({
|
||||
@@ -52,6 +53,7 @@ export function useCanvasReconnectHandlers({
|
||||
validateConnection,
|
||||
resolveMixerSwapReconnect,
|
||||
onInvalidConnection,
|
||||
clearActiveMagnetTarget,
|
||||
}: UseCanvasReconnectHandlersParams): {
|
||||
onReconnectStart: () => void;
|
||||
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||
@@ -72,10 +74,11 @@ export function useCanvasReconnectHandlers({
|
||||
>(null);
|
||||
|
||||
const onReconnectStart = useCallback(() => {
|
||||
clearActiveMagnetTarget?.();
|
||||
edgeReconnectSuccessful.current = false;
|
||||
isReconnectDragActiveRef.current = true;
|
||||
pendingReconnectRef.current = null;
|
||||
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
||||
}, [clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
||||
|
||||
const onReconnect = useCallback(
|
||||
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||
@@ -201,11 +204,13 @@ export function useCanvasReconnectHandlers({
|
||||
|
||||
edgeReconnectSuccessful.current = true;
|
||||
} finally {
|
||||
clearActiveMagnetTarget?.();
|
||||
isReconnectDragActiveRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
canvasId,
|
||||
clearActiveMagnetTarget,
|
||||
edgeReconnectSuccessful,
|
||||
isReconnectDragActiveRef,
|
||||
runCreateEdgeMutation,
|
||||
|
||||
@@ -78,6 +78,8 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
|
||||
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
||||
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
||||
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 {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -675,6 +677,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
panOnDrag={flowPanOnDrag}
|
||||
selectionOnDrag={flowSelectionOnDrag}
|
||||
panActivationKeyCode="Space"
|
||||
connectionRadius={HANDLE_GLOW_RADIUS_PX}
|
||||
reconnectRadius={24}
|
||||
edgesReconnectable
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
className={cn(
|
||||
@@ -709,7 +714,9 @@ interface CanvasProps {
|
||||
export default function Canvas({ canvasId }: CanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasConnectionMagnetismProvider>
|
||||
<CanvasInner canvasId={canvasId} />
|
||||
</CanvasConnectionMagnetismProvider>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,43 @@ import {
|
||||
getSmoothStepPath,
|
||||
getStraightPath,
|
||||
type ConnectionLineComponentProps,
|
||||
useConnection,
|
||||
useReactFlow,
|
||||
} 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({
|
||||
connectionLineType,
|
||||
@@ -21,13 +56,74 @@ export default function CustomConnectionLine({
|
||||
fromPosition,
|
||||
toPosition,
|
||||
connectionStatus,
|
||||
pointer,
|
||||
}: 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 = {
|
||||
sourceX: fromX,
|
||||
sourceY: fromY,
|
||||
sourcePosition: fromPosition,
|
||||
targetX: toX,
|
||||
targetY: toY,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition: toPosition,
|
||||
};
|
||||
|
||||
@@ -52,8 +148,19 @@ export default function CustomConnectionLine({
|
||||
[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 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 (
|
||||
<path
|
||||
@@ -62,9 +169,10 @@ export default function CustomConnectionLine({
|
||||
className="ls-connection-line-marching"
|
||||
style={{
|
||||
stroke: `rgb(${r}, ${g}, ${b})`,
|
||||
strokeWidth: 2.5,
|
||||
strokeWidth,
|
||||
strokeLinecap: "round",
|
||||
strokeDasharray: "10 8",
|
||||
filter,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import type { FunctionReference } from "convex/server";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type AgentNodeData = {
|
||||
templateId?: string;
|
||||
@@ -466,13 +467,17 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
|
||||
statusMessage={nodeData._statusMessage}
|
||||
className="min-w-[300px] border-amber-500/30"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="agent"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="agent-in"
|
||||
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
|
||||
/>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="agent"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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 BaseNodeWrapper from "./base-node-wrapper";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type AgentOutputNodeData = {
|
||||
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 nodeData = data as AgentOutputNodeData;
|
||||
const isSkeleton = nodeData.isSkeleton === true;
|
||||
@@ -240,7 +241,9 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
statusMessage={nodeData._statusMessage}
|
||||
className={`min-w-[300px] border-amber-500/30 ${isSkeleton ? "opacity-80" : ""}`}
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="agent-output"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="agent-output-in"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type AiImageNodeData = {
|
||||
storageId?: string;
|
||||
@@ -194,7 +195,9 @@ export default function AiImageNode({
|
||||
]}
|
||||
className="flex h-full w-full min-h-0 min-w-0 flex-col"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="ai-image"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="prompt-in"
|
||||
@@ -331,7 +334,9 @@ export default function AiImageNode({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="ai-image"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="image-out"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAction } from "convex/react";
|
||||
import type { FunctionReference } from "convex/server";
|
||||
import { useTranslations } from "next-intl";
|
||||
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 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 { toast } from "@/lib/toast";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type AiVideoNodeData = {
|
||||
prompt?: string;
|
||||
@@ -160,7 +161,9 @@ export default function AiVideoNode({ id, data, selected }: NodeProps<AiVideoNod
|
||||
statusMessage={nodeData._statusMessage}
|
||||
className="flex h-full w-full min-h-0 min-w-0 flex-col"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="ai-video"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="video-in"
|
||||
@@ -240,7 +243,9 @@ export default function AiVideoNode({ id, data, selected }: NodeProps<AiVideoNod
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="ai-video"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="video-out"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useState,
|
||||
type MouseEvent,
|
||||
} 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 BaseNodeWrapper from "./base-node-wrapper";
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type AssetNodeData = {
|
||||
assetId?: number;
|
||||
@@ -152,7 +153,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
status={data._status}
|
||||
statusMessage={data._statusMessage}
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="asset"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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}
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="asset"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Palette } from "lucide-react";
|
||||
|
||||
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||
import { COLOR_PRESETS } from "@/lib/image-pipeline/presets";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "@/lib/toast";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type ColorAdjustNodeData = ColorAdjustData & {
|
||||
_status?: string;
|
||||
@@ -191,7 +192,9 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
statusMessage={data._statusMessage}
|
||||
className="min-w-[300px] border-cyan-500/30"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="color-adjust"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="color-adjust"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 BaseNodeWrapper from "./base-node-wrapper";
|
||||
import CompareSurface from "./compare-surface";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveMixerPreviewFromGraph,
|
||||
type MixerPreviewState,
|
||||
} from "@/lib/canvas-mixer-preview";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
interface CompareNodeData {
|
||||
leftUrl?: string;
|
||||
@@ -242,21 +243,27 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="compare"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
style={{ top: "35%" }}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-blue-500"
|
||||
/>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="compare"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="right"
|
||||
style={{ top: "55%" }}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||
/>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="compare"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="compare-out"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { useTranslations } from "next-intl";
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type CropNodeViewData = CropNodeData & {
|
||||
_status?: string;
|
||||
@@ -400,7 +401,9 @@ export default function CropNode({ id, data, selected, width }: NodeProps<CropNo
|
||||
statusMessage={data._statusMessage}
|
||||
className="min-w-[320px] border-violet-500/30"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="crop"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="crop"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { TrendingUp } from "lucide-react";
|
||||
|
||||
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||
import { CURVE_PRESETS } from "@/lib/image-pipeline/presets";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "@/lib/toast";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type CurvesNodeData = CurvesData & {
|
||||
_status?: string;
|
||||
@@ -163,7 +164,9 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
statusMessage={data._statusMessage}
|
||||
className="min-w-[300px] border-emerald-500/30"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="curves"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="curves"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Focus } from "lucide-react";
|
||||
|
||||
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||
import { DETAIL_PRESETS } from "@/lib/image-pipeline/presets";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "@/lib/toast";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type DetailAdjustNodeData = DetailAdjustData & {
|
||||
_status?: string;
|
||||
@@ -202,7 +203,9 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
statusMessage={data._statusMessage}
|
||||
className="min-w-[300px] border-indigo-500/30"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="detail-adjust"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="detail-adjust"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { useTranslations } from "next-intl";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
@@ -11,6 +11,7 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
interface FrameNodeData {
|
||||
label?: string;
|
||||
@@ -125,13 +126,17 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
|
||||
|
||||
<div className="nodrag h-full w-full" />
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="frame"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="frame-in"
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-orange-500"
|
||||
/>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="frame"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="frame-out"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
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 BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type GroupNodeData = {
|
||||
label?: string;
|
||||
@@ -47,7 +48,9 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
||||
selected={selected}
|
||||
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="group"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="group"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
} 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 { useTranslations } from "next-intl";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getImageDimensions,
|
||||
} from "@/components/canvas/canvas-media-utils";
|
||||
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = new Set([
|
||||
"image/png",
|
||||
@@ -508,7 +509,9 @@ export default function ImageNode({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="image"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||
@@ -609,7 +612,9 @@ export default function ImageNode({
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="image"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Sun } from "lucide-react";
|
||||
|
||||
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||
import { LIGHT_PRESETS } from "@/lib/image-pipeline/presets";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "@/lib/toast";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type LightAdjustNodeData = LightAdjustData & {
|
||||
_status?: string;
|
||||
@@ -213,7 +214,9 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
statusMessage={data._statusMessage}
|
||||
className="min-w-[300px] border-amber-500/30"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="light-adjust"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="light-adjust"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type MixerBlendMode,
|
||||
} from "@/lib/canvas-mixer-preview";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"];
|
||||
|
||||
@@ -56,21 +57,27 @@ export default function MixerNode({ id, data, selected }: NodeProps) {
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="mixer" selected={selected} className="p-0">
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="mixer"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="base"
|
||||
style={{ top: "35%" }}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
||||
/>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="mixer"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="overlay"
|
||||
style={{ top: "58%" }}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-pink-500"
|
||||
/>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="mixer"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="mixer-out"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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 { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type NoteNodeData = {
|
||||
content?: string;
|
||||
@@ -53,7 +54,9 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="note" selected={selected} className="p-3">
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="note"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="note"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Handle,
|
||||
Position,
|
||||
useReactFlow,
|
||||
useStore,
|
||||
@@ -45,6 +44,7 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { classifyError } from "@/lib/ai-errors";
|
||||
import { normalizePublicTier } from "@/lib/tier-credits";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type PromptNodeData = {
|
||||
prompt?: string;
|
||||
@@ -353,7 +353,9 @@ export default function PromptNode({
|
||||
statusMessage={nodeData._statusMessage}
|
||||
className="min-w-[240px] border-violet-500/30"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="prompt"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="image-in"
|
||||
@@ -489,7 +491,9 @@ export default function PromptNode({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="prompt"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="prompt-out"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { useMutation } from "convex/react";
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type RenderResolutionOption = "original" | "2x" | "custom";
|
||||
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"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="render"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="render"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
Handle,
|
||||
Position,
|
||||
useReactFlow,
|
||||
type NodeProps,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type TextNodeData = {
|
||||
content?: string;
|
||||
@@ -155,7 +155,9 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
||||
]}
|
||||
className="relative"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="text"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
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>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="text"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Play } from "lucide-react";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type VideoNodeData = {
|
||||
canvasId?: string;
|
||||
@@ -150,7 +151,9 @@ export default function VideoNode({
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="video" selected={selected}>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="video"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||
@@ -245,7 +248,9 @@ export default function VideoNode({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="video"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 type { FunctionReference } from "convex/server";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||
|
||||
type VideoPromptNodeData = {
|
||||
prompt?: string;
|
||||
@@ -300,7 +301,9 @@ export default function VideoPromptNode({
|
||||
statusMessage={nodeData._statusMessage}
|
||||
className="min-w-[260px] border-violet-500/30"
|
||||
>
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="video-prompt"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="video-prompt-in"
|
||||
@@ -407,7 +410,9 @@ export default function VideoPromptNode({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
<CanvasHandle
|
||||
nodeId={id}
|
||||
nodeType="video-prompt"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="video-prompt-out"
|
||||
|
||||
@@ -10,6 +10,10 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
|
||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||
|
||||
import {
|
||||
resolveCanvasMagnetTarget,
|
||||
type CanvasMagnetTarget,
|
||||
} from "./canvas-connection-magnetism";
|
||||
import {
|
||||
getConnectEndClientPoint,
|
||||
hasHandleKey,
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
validateCanvasConnectionByType,
|
||||
validateCanvasEdgeSplit,
|
||||
} from "./canvas-connection-validation";
|
||||
import { useCanvasConnectionMagnetism } from "./canvas-connection-magnetism-context";
|
||||
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
||||
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
||||
|
||||
@@ -122,6 +127,7 @@ export function useCanvasConnections({
|
||||
runSwapMixerInputsMutation,
|
||||
showConnectionRejectedToast,
|
||||
}: UseCanvasConnectionsParams) {
|
||||
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||
const [connectionDropMenu, setConnectionDropMenu] =
|
||||
useState<ConnectionDropMenuState | null>(null);
|
||||
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
||||
@@ -133,17 +139,40 @@ export function useCanvasConnections({
|
||||
}, [connectionDropMenu]);
|
||||
|
||||
const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
|
||||
setActiveTarget(null);
|
||||
isConnectDragActiveRef.current = true;
|
||||
logCanvasConnectionDebug("connect:start", {
|
||||
nodeId: params.nodeId,
|
||||
handleId: params.handleId,
|
||||
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(
|
||||
(connection: Connection) => {
|
||||
isConnectDragActiveRef.current = false;
|
||||
try {
|
||||
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||
if (validationError) {
|
||||
logCanvasConnectionDebug("connect:invalid-direct", {
|
||||
@@ -181,8 +210,11 @@ export function useCanvasConnections({
|
||||
sourceHandle: connection.sourceHandle ?? undefined,
|
||||
targetHandle: connection.targetHandle ?? undefined,
|
||||
});
|
||||
} finally {
|
||||
setActiveTarget(null);
|
||||
}
|
||||
},
|
||||
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
|
||||
[canvasId, edges, nodes, runCreateEdgeMutation, setActiveTarget, showConnectionRejectedToast],
|
||||
);
|
||||
|
||||
const resolveMixerSwapReconnect = useCallback(
|
||||
@@ -252,6 +284,7 @@ export function useCanvasConnections({
|
||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||
(event, connectionState) => {
|
||||
if (!isConnectDragActiveRef.current) {
|
||||
setActiveTarget(null);
|
||||
logCanvasConnectionDebug("connect:end-ignored", {
|
||||
reason: "drag-not-active",
|
||||
isValid: connectionState.isValid ?? null,
|
||||
@@ -264,6 +297,7 @@ export function useCanvasConnections({
|
||||
}
|
||||
|
||||
isConnectDragActiveRef.current = false;
|
||||
try {
|
||||
if (isReconnectDragActiveRef.current) {
|
||||
logCanvasConnectionDebug("connect:end-ignored", {
|
||||
reason: "reconnect-active",
|
||||
@@ -319,7 +353,7 @@ export function useCanvasConnections({
|
||||
});
|
||||
|
||||
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
||||
const droppedConnection = resolveDroppedConnectionTarget({
|
||||
let droppedConnection = resolveDroppedConnectionTarget({
|
||||
point: pt,
|
||||
fromNodeId: fromNode.id,
|
||||
fromHandleId: fromHandle.id ?? undefined,
|
||||
@@ -328,6 +362,28 @@ export function useCanvasConnections({
|
||||
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", {
|
||||
point: pt,
|
||||
flow,
|
||||
@@ -445,6 +501,9 @@ export function useCanvasConnections({
|
||||
fromHandleId: fromHandle.id ?? undefined,
|
||||
fromHandleType: fromHandle.type,
|
||||
});
|
||||
} finally {
|
||||
setActiveTarget(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
canvasId,
|
||||
@@ -454,7 +513,10 @@ export function useCanvasConnections({
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
screenToFlowPosition,
|
||||
setActiveTarget,
|
||||
showConnectionRejectedToast,
|
||||
activeTarget,
|
||||
toDroppedConnectionFromMagnetTarget,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -598,6 +660,9 @@ export function useCanvasConnections({
|
||||
onInvalidConnection: (reason) => {
|
||||
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
|
||||
},
|
||||
clearActiveMagnetTarget: () => {
|
||||
setActiveTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -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_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`)
|
||||
- `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" }`)
|
||||
- `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung
|
||||
- `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen
|
||||
|
||||
@@ -102,7 +102,9 @@ export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
|
||||
* Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente).
|
||||
* 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],
|
||||
"video-prompt": [124, 58, 237],
|
||||
"ai-image": [139, 92, 246],
|
||||
@@ -123,21 +125,158 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
|
||||
render: [14, 165, 233],
|
||||
agent: [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). */
|
||||
const COMPARE_HANDLE_CONNECTION_RGB: Record<
|
||||
string,
|
||||
readonly [number, number, number]
|
||||
> = {
|
||||
const COMPARE_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
|
||||
left: [59, 130, 246],
|
||||
right: [16, 185, 129],
|
||||
"compare-out": [100, 116, 139],
|
||||
};
|
||||
|
||||
const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
|
||||
13, 148, 136,
|
||||
];
|
||||
const MIXER_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
|
||||
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).
|
||||
@@ -145,13 +284,12 @@ const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
|
||||
export function connectionLineAccentRgb(
|
||||
nodeType: string | undefined,
|
||||
handleId: string | null | undefined,
|
||||
): readonly [number, number, number] {
|
||||
if (nodeType === "compare" && handleId) {
|
||||
const byHandle = COMPARE_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;
|
||||
): RgbColor {
|
||||
return canvasHandleAccentRgb({
|
||||
nodeType,
|
||||
handleId,
|
||||
handleType: "source",
|
||||
});
|
||||
}
|
||||
|
||||
export type EdgeGlowColorMode = "light" | "dark";
|
||||
|
||||
@@ -142,6 +142,7 @@ vi.mock("next-intl", () => ({
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
|
||||
import AgentNode from "@/components/canvas/nodes/agent-node";
|
||||
|
||||
@@ -60,6 +60,7 @@ vi.mock("@xyflow/react", () => ({
|
||||
});
|
||||
},
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock("@xyflow/react", () => ({
|
||||
});
|
||||
},
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
useReactFlow: () => ({
|
||||
getEdges: mocks.getEdges,
|
||||
getNode: mocks.getNode,
|
||||
|
||||
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
|
||||
@@ -19,6 +19,7 @@ const parameterSliderState = vi.hoisted(() => ({
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
|
||||
@@ -135,6 +135,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
|
||||
selector({ edges: mocks.edges, nodes: mocks.nodes }),
|
||||
useReactFlow: () => ({
|
||||
|
||||
@@ -691,6 +691,7 @@ describe("preview histogram call sites", () => {
|
||||
vi.doMock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
vi.doMock("convex/react", () => ({
|
||||
useMutation: () => vi.fn(async () => undefined),
|
||||
@@ -754,6 +755,8 @@ describe("preview histogram call sites", () => {
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-utils", () => ({
|
||||
resolveMediaAspectRatio: () => null,
|
||||
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
|
||||
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
|
||||
}));
|
||||
vi.doMock("@/lib/image-formats", () => ({
|
||||
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
||||
@@ -875,6 +878,7 @@ describe("preview histogram call sites", () => {
|
||||
vi.doMock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
vi.doMock("convex/react", () => ({
|
||||
useMutation: () => vi.fn(async () => undefined),
|
||||
@@ -935,6 +939,8 @@ describe("preview histogram call sites", () => {
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-utils", () => ({
|
||||
resolveMediaAspectRatio: () => null,
|
||||
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
|
||||
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
|
||||
}));
|
||||
vi.doMock("@/lib/image-formats", () => ({
|
||||
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
||||
@@ -1063,6 +1069,7 @@ describe("preview histogram call sites", () => {
|
||||
vi.doMock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
}));
|
||||
vi.doMock("convex/react", () => ({
|
||||
useMutation: () => vi.fn(async () => undefined),
|
||||
@@ -1126,6 +1133,8 @@ describe("preview histogram call sites", () => {
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-utils", () => ({
|
||||
resolveMediaAspectRatio: () => null,
|
||||
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
|
||||
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
|
||||
}));
|
||||
vi.doMock("@/lib/image-formats", () => ({
|
||||
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
||||
|
||||
@@ -123,6 +123,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
useConnection: () => ({ inProgress: false }),
|
||||
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
|
||||
selector({ edges: mocks.edges, nodes: mocks.nodes }),
|
||||
useReactFlow: () => ({
|
||||
|
||||
@@ -25,6 +25,8 @@ export default defineConfig({
|
||||
"components/canvas/__tests__/use-canvas-edge-types.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-node-interactions.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__/base-node-wrapper.test.tsx",
|
||||
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
||||
|
||||
Reference in New Issue
Block a user