Merge branch 'feat/canvas-magnetism-20260411-082412'
This commit is contained in:
@@ -190,6 +190,11 @@
|
|||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reconnect-Anker immer pointer-interactive halten (Drag-Detach/Reconnect) */
|
||||||
|
.react-flow__edgeupdater {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
/* Proximity-Vorschaukante (temp) */
|
/* Proximity-Vorschaukante (temp) */
|
||||||
.react-flow__edge.temp {
|
.react-flow__edge.temp {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
|||||||
@@ -35,10 +35,21 @@ app/(app)/canvas/[canvasId]/page.tsx
|
|||||||
| `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) |
|
| `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) |
|
||||||
| `canvas-delete-handlers.ts` | Hook für `onBeforeDelete`, `onNodesDelete`, `onEdgesDelete` inkl. Bridge-Edges |
|
| `canvas-delete-handlers.ts` | Hook für `onBeforeDelete`, `onNodesDelete`, `onEdgesDelete` inkl. Bridge-Edges |
|
||||||
| `canvas-reconnect.ts` | Hook für Edge-Reconnect (`onReconnectStart`, `onReconnect`, `onReconnectEnd`) |
|
| `canvas-reconnect.ts` | Hook für Edge-Reconnect (`onReconnectStart`, `onReconnect`, `onReconnectEnd`) |
|
||||||
|
| `canvas-connection-magnetism.ts` | Pure Magnet-Resolver für Handle-Proximity (`resolveCanvasMagnetTarget`) inkl. Glow/Snap-Radien |
|
||||||
|
| `canvas-connection-magnetism-context.tsx` | Transienter Client-State für aktives Magnet-Target während Connect/Reconnect-Drags |
|
||||||
| `canvas-media-utils.ts` | Media-Helfer wie `getImageDimensions(file)` |
|
| `canvas-media-utils.ts` | Media-Helfer wie `getImageDimensions(file)` |
|
||||||
| `use-canvas-data.ts` | Hook: Bündelt Canvas-Graph-Query, Storage-URL-Auflösung und Auth-State in einer einzigen Abstraktion |
|
| `use-canvas-data.ts` | Hook: Bündelt Canvas-Graph-Query, Storage-URL-Auflösung und Auth-State in einer einzigen Abstraktion |
|
||||||
| `canvas-graph-query-cache.ts` | Optimistic Store Helper für `canvasGraph.get` (getNodes, getEdges, setNodes, setEdges) |
|
| `canvas-graph-query-cache.ts` | Optimistic Store Helper für `canvasGraph.get` (getNodes, getEdges, setNodes, setEdges) |
|
||||||
|
|
||||||
|
### Connection Magnetism (client-only)
|
||||||
|
|
||||||
|
- Magnetism ist eine rein clientseitige UX-Schicht über dem bestehenden React-Flow-Connect-Flow; Persistenz, Edge-Schema und Convex-Mutations bleiben unverändert.
|
||||||
|
- `HANDLE_GLOW_RADIUS_PX = 56` und `HANDLE_SNAP_RADIUS_PX = 40` liegen zentral in `canvas-connection-magnetism.ts` und werden von Resolver, Handle-Glow und Connection-Line gemeinsam genutzt.
|
||||||
|
- `resolveCanvasMagnetTarget(...)` sucht LemonSpace-eigene Handle-DOM-Kandidaten über `data-node-id` / `data-handle-id` / `data-handle-type`, berechnet die Distanz zum Pointer und wählt stabil das nächste gültige Handle.
|
||||||
|
- `CanvasConnectionMagnetismProvider` (in `canvas.tsx`) stellt `activeTarget` und `setActiveTarget` für `CustomConnectionLine`, `CanvasHandle` und Connect/Reconnect-Hooks bereit; der State ist transient und wird nach Drag-Ende geleert.
|
||||||
|
- `CanvasHandle` ist der gemeinsame Wrapper für alle Node-Handles (statt direktes `<Handle>` pro Node), rendert `idle|near|snapped` Glow-States und exportiert stabile `data-*` Attribute für die Geometrie-Lookups.
|
||||||
|
- Connectability bleibt strikt policy-getrieben: Magnet-Targets werden nur akzeptiert, wenn `validateCanvasConnectionPolicy(...)` bzw. die bestehende Validierungslogik die Verbindung erlaubt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Node-Taxonomie (Phase 1)
|
## Node-Taxonomie (Phase 1)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpe
|
|||||||
|
|
||||||
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
||||||
return {
|
return {
|
||||||
id: overrides.id,
|
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {},
|
data: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -40,6 +39,34 @@ function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeHandleElement(args: {
|
||||||
|
nodeId: string;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
handleId?: string;
|
||||||
|
rect: Partial<DOMRect>;
|
||||||
|
}): HTMLElement {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.className = "react-flow__handle";
|
||||||
|
element.dataset.nodeId = args.nodeId;
|
||||||
|
element.dataset.handleType = args.handleType;
|
||||||
|
if (args.handleId !== undefined) {
|
||||||
|
element.dataset.handleId = args.handleId;
|
||||||
|
}
|
||||||
|
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||||
|
x: args.rect.left ?? 0,
|
||||||
|
y: args.rect.top ?? 0,
|
||||||
|
top: args.rect.top ?? 0,
|
||||||
|
left: args.rect.left ?? 0,
|
||||||
|
right: args.rect.right ?? 10,
|
||||||
|
bottom: args.rect.bottom ?? 10,
|
||||||
|
width: args.rect.width ?? 10,
|
||||||
|
height: args.rect.height ?? 10,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect);
|
||||||
|
document.body.appendChild(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
describe("resolveDroppedConnectionTarget", () => {
|
describe("resolveDroppedConnectionTarget", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -144,6 +171,169 @@ describe("resolveDroppedConnectionTarget", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves nearest valid target handle even without a node body hit", () => {
|
||||||
|
const sourceNode = createNode({
|
||||||
|
id: "node-source",
|
||||||
|
type: "image",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const compareNode = createNode({
|
||||||
|
id: "node-compare",
|
||||||
|
type: "compare",
|
||||||
|
position: { x: 320, y: 200 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => []),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "left",
|
||||||
|
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "right",
|
||||||
|
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 364, y: 258 },
|
||||||
|
fromNodeId: "node-source",
|
||||||
|
fromHandleType: "source",
|
||||||
|
nodes: [sourceNode, compareNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
sourceNodeId: "node-source",
|
||||||
|
targetNodeId: "node-compare",
|
||||||
|
sourceHandle: undefined,
|
||||||
|
targetHandle: "left",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a closer invalid handle and picks the nearest valid handle", () => {
|
||||||
|
const sourceNode = createNode({
|
||||||
|
id: "node-source",
|
||||||
|
type: "image",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const mixerNode = createNode({
|
||||||
|
id: "node-mixer",
|
||||||
|
type: "mixer",
|
||||||
|
position: { x: 320, y: 200 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => []),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-mixer",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "base",
|
||||||
|
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-mixer",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "overlay",
|
||||||
|
rect: { left: 386, top: 278, width: 12, height: 12, right: 398, bottom: 290 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 364, y: 258 },
|
||||||
|
fromNodeId: "node-source",
|
||||||
|
fromHandleType: "source",
|
||||||
|
nodes: [sourceNode, mixerNode],
|
||||||
|
edges: [
|
||||||
|
createEdge({
|
||||||
|
id: "edge-base-taken",
|
||||||
|
source: "node-source",
|
||||||
|
target: "node-mixer",
|
||||||
|
targetHandle: "base",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
sourceNodeId: "node-source",
|
||||||
|
targetNodeId: "node-mixer",
|
||||||
|
sourceHandle: undefined,
|
||||||
|
targetHandle: "overlay",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the actually nearest handle for compare and mixer targets", () => {
|
||||||
|
const sourceNode = createNode({
|
||||||
|
id: "node-source",
|
||||||
|
type: "image",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const compareNode = createNode({
|
||||||
|
id: "node-compare",
|
||||||
|
type: "compare",
|
||||||
|
position: { x: 320, y: 200 },
|
||||||
|
});
|
||||||
|
const mixerNode = createNode({
|
||||||
|
id: "node-mixer",
|
||||||
|
type: "mixer",
|
||||||
|
position: { x: 640, y: 200 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => []),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "left",
|
||||||
|
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "right",
|
||||||
|
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-mixer",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "base",
|
||||||
|
rect: { left: 678, top: 252, width: 12, height: 12, right: 690, bottom: 264 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-mixer",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "overlay",
|
||||||
|
rect: { left: 678, top: 292, width: 12, height: 12, right: 690, bottom: 304 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const compareResult = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 364, y: 258 },
|
||||||
|
fromNodeId: "node-source",
|
||||||
|
fromHandleType: "source",
|
||||||
|
nodes: [sourceNode, compareNode, mixerNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixerResult = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 684, y: 299 },
|
||||||
|
fromNodeId: "node-source",
|
||||||
|
fromHandleType: "source",
|
||||||
|
nodes: [sourceNode, compareNode, mixerNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compareResult?.targetHandle).toBe("left");
|
||||||
|
expect(mixerResult?.targetHandle).toBe("overlay");
|
||||||
|
});
|
||||||
|
|
||||||
it("reverses the connection when the drag starts from a target handle", () => {
|
it("reverses the connection when the drag starts from a target handle", () => {
|
||||||
const droppedNode = createNode({
|
const droppedNode = createNode({
|
||||||
id: "node-dropped",
|
id: "node-dropped",
|
||||||
@@ -177,4 +367,44 @@ describe("resolveDroppedConnectionTarget", () => {
|
|||||||
targetHandle: "target-handle",
|
targetHandle: "target-handle",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves nearest source handle when drag starts from target handle", () => {
|
||||||
|
const fromNode = createNode({
|
||||||
|
id: "node-compare-target",
|
||||||
|
type: "compare",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const compareNode = createNode({
|
||||||
|
id: "node-compare-source",
|
||||||
|
type: "compare",
|
||||||
|
position: { x: 320, y: 200 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => []),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare-source",
|
||||||
|
handleType: "source",
|
||||||
|
handleId: "compare-out",
|
||||||
|
rect: { left: 478, top: 288, width: 12, height: 12, right: 490, bottom: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 484, y: 294 },
|
||||||
|
fromNodeId: "node-compare-target",
|
||||||
|
fromHandleId: "left",
|
||||||
|
fromHandleType: "target",
|
||||||
|
nodes: [fromNode, compareNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
sourceNodeId: "node-compare-source",
|
||||||
|
targetNodeId: "node-compare-target",
|
||||||
|
sourceHandle: "compare-out",
|
||||||
|
targetHandle: "left",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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),
|
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/canvas-handle", () => ({
|
||||||
|
default: ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
nodeId,
|
||||||
|
nodeType,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
type: "source" | "target";
|
||||||
|
nodeId: string;
|
||||||
|
nodeType?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-canvas-handle="true"
|
||||||
|
data-handle-id={id ?? ""}
|
||||||
|
data-handle-type={type}
|
||||||
|
data-node-id={nodeId}
|
||||||
|
data-node-type={nodeType ?? ""}
|
||||||
|
data-top={typeof style?.top === "string" ? style.top : ""}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../nodes/base-node-wrapper", () => ({
|
vi.mock("../nodes/base-node-wrapper", () => ({
|
||||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
@@ -261,4 +286,35 @@ describe("CompareNode render preview inputs", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders compare handles through CanvasHandle with preserved ids and positions", () => {
|
||||||
|
const markup = renderCompareNode({
|
||||||
|
id: "compare-1",
|
||||||
|
data: {},
|
||||||
|
selected: false,
|
||||||
|
dragging: false,
|
||||||
|
zIndex: 0,
|
||||||
|
isConnectable: true,
|
||||||
|
type: "compare",
|
||||||
|
xPos: 0,
|
||||||
|
yPos: 0,
|
||||||
|
width: 500,
|
||||||
|
height: 380,
|
||||||
|
sourcePosition: undefined,
|
||||||
|
targetPosition: undefined,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(markup).toContain('data-canvas-handle="true"');
|
||||||
|
expect(markup).toContain('data-node-id="compare-1"');
|
||||||
|
expect(markup).toContain('data-node-type="compare"');
|
||||||
|
expect(markup).toContain('data-handle-id="left"');
|
||||||
|
expect(markup).toContain('data-handle-id="right"');
|
||||||
|
expect(markup).toContain('data-handle-id="compare-out"');
|
||||||
|
expect(markup).toContain('data-handle-type="target"');
|
||||||
|
expect(markup).toContain('data-handle-type="source"');
|
||||||
|
expect(markup).toContain('data-top="35%"');
|
||||||
|
expect(markup).toContain('data-top="55%"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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" },
|
Position: { Left: "left", Right: "right" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/canvas-handle", () => ({
|
||||||
|
default: ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
nodeId,
|
||||||
|
nodeType,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
type: "source" | "target";
|
||||||
|
nodeId: string;
|
||||||
|
nodeType?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-canvas-handle="true"
|
||||||
|
data-handle-id={id ?? ""}
|
||||||
|
data-handle-type={type}
|
||||||
|
data-node-id={nodeId}
|
||||||
|
data-node-type={nodeType ?? ""}
|
||||||
|
data-top={typeof style?.top === "string" ? style.top : ""}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/canvas/canvas-sync-context", () => ({
|
vi.mock("@/components/canvas/canvas-sync-context", () => ({
|
||||||
useCanvasSync: () => ({
|
useCanvasSync: () => ({
|
||||||
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
|
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
|
||||||
@@ -222,8 +247,20 @@ describe("MixerNode", () => {
|
|||||||
it("renders expected mixer handles", async () => {
|
it("renders expected mixer handles", async () => {
|
||||||
await renderNode();
|
await renderNode();
|
||||||
|
|
||||||
expect(container?.querySelector('[data-handle-id="base"][data-handle-type="target"]')).toBeTruthy();
|
expect(
|
||||||
expect(container?.querySelector('[data-handle-id="overlay"][data-handle-type="target"]')).toBeTruthy();
|
container?.querySelector(
|
||||||
expect(container?.querySelector('[data-handle-id="mixer-out"][data-handle-type="source"]')).toBeTruthy();
|
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="base"][data-handle-type="target"][data-top="35%"]',
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
container?.querySelector(
|
||||||
|
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="overlay"][data-handle-type="target"][data-top="58%"]',
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
container?.querySelector(
|
||||||
|
'[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="mixer-out"][data-handle-type="source"]',
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
resolveDroppedConnectionTarget: vi.fn(),
|
resolveDroppedConnectionTarget: vi.fn(),
|
||||||
|
resolveCanvasMagnetTarget: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/canvas/canvas-helpers", async () => {
|
vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||||
@@ -22,8 +24,23 @@ vi.mock("@/components/canvas/canvas-helpers", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/canvas-connection-magnetism", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@/components/canvas/canvas-connection-magnetism")
|
||||||
|
>("@/components/canvas/canvas-connection-magnetism");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveCanvasMagnetTarget: mocks.resolveCanvasMagnetTarget,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
||||||
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||||
|
import {
|
||||||
|
CanvasConnectionMagnetismProvider,
|
||||||
|
useCanvasConnectionMagnetism,
|
||||||
|
} from "@/components/canvas/canvas-connection-magnetism-context";
|
||||||
import { nodeTypes } from "@/components/canvas/node-types";
|
import { nodeTypes } from "@/components/canvas/node-types";
|
||||||
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
||||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||||
@@ -35,6 +52,14 @@ const latestHandlersRef: {
|
|||||||
current: ReturnType<typeof useCanvasConnections> | null;
|
current: ReturnType<typeof useCanvasConnections> | null;
|
||||||
} = { current: null };
|
} = { current: null };
|
||||||
|
|
||||||
|
const latestMagnetTargetRef: {
|
||||||
|
current: CanvasMagnetTarget | null;
|
||||||
|
} = { current: null };
|
||||||
|
|
||||||
|
const latestSetActiveTargetRef: {
|
||||||
|
current: ((target: CanvasMagnetTarget | null) => void) | null;
|
||||||
|
} = { current: null };
|
||||||
|
|
||||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
type HookHarnessProps = {
|
type HookHarnessProps = {
|
||||||
@@ -47,9 +72,12 @@ type HookHarnessProps = {
|
|||||||
setEdgesMock?: ReturnType<typeof vi.fn>;
|
setEdgesMock?: ReturnType<typeof vi.fn>;
|
||||||
nodes?: RFNode[];
|
nodes?: RFNode[];
|
||||||
edges?: RFEdge[];
|
edges?: RFEdge[];
|
||||||
|
initialMagnetTarget?: CanvasMagnetTarget | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function HookHarness({
|
type HookHarnessInnerProps = HookHarnessProps;
|
||||||
|
|
||||||
|
function HookHarnessInner({
|
||||||
helperResult,
|
helperResult,
|
||||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||||
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||||
@@ -59,7 +87,10 @@ function HookHarness({
|
|||||||
setEdgesMock,
|
setEdgesMock,
|
||||||
nodes: providedNodes,
|
nodes: providedNodes,
|
||||||
edges: providedEdges,
|
edges: providedEdges,
|
||||||
}: HookHarnessProps) {
|
initialMagnetTarget,
|
||||||
|
}: HookHarnessInnerProps) {
|
||||||
|
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||||
|
const didInitializeMagnetTargetRef = useRef(false);
|
||||||
const [nodes] = useState<RFNode[]>(
|
const [nodes] = useState<RFNode[]>(
|
||||||
providedNodes ?? [
|
providedNodes ?? [
|
||||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||||
@@ -88,6 +119,17 @@ function HookHarness({
|
|||||||
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
|
||||||
}, [helperResult]);
|
}, [helperResult]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mocks.resolveCanvasMagnetTarget.mockReturnValue(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) {
|
||||||
|
didInitializeMagnetTargetRef.current = true;
|
||||||
|
setActiveTarget(initialMagnetTarget);
|
||||||
|
}
|
||||||
|
}, [initialMagnetTarget, setActiveTarget]);
|
||||||
|
|
||||||
const handlers = useCanvasConnections({
|
const handlers = useCanvasConnections({
|
||||||
canvasId: asCanvasId("canvas-1"),
|
canvasId: asCanvasId("canvas-1"),
|
||||||
nodes,
|
nodes,
|
||||||
@@ -115,15 +157,36 @@ function HookHarness({
|
|||||||
latestHandlersRef.current = handlers;
|
latestHandlersRef.current = handlers;
|
||||||
}, [handlers]);
|
}, [handlers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestMagnetTargetRef.current = activeTarget;
|
||||||
|
}, [activeTarget]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestSetActiveTargetRef.current = setActiveTarget;
|
||||||
|
return () => {
|
||||||
|
latestSetActiveTargetRef.current = null;
|
||||||
|
};
|
||||||
|
}, [setActiveTarget]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HookHarness(props: HookHarnessProps) {
|
||||||
|
return (
|
||||||
|
<CanvasConnectionMagnetismProvider>
|
||||||
|
<HookHarnessInner {...props} />
|
||||||
|
</CanvasConnectionMagnetismProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("useCanvasConnections", () => {
|
describe("useCanvasConnections", () => {
|
||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
let root: Root | null = null;
|
let root: Root | null = null;
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
latestHandlersRef.current = null;
|
latestHandlersRef.current = null;
|
||||||
|
latestMagnetTargetRef.current = null;
|
||||||
|
latestSetActiveTargetRef.current = null;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
if (root) {
|
if (root) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => {
|
|||||||
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
||||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to active magnet target when direct drop resolution misses", async () => {
|
||||||
|
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<HookHarness
|
||||||
|
helperResult={null}
|
||||||
|
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||||
|
initialMagnetTarget={{
|
||||||
|
nodeId: "node-target",
|
||||||
|
handleId: "base",
|
||||||
|
handleType: "target",
|
||||||
|
centerX: 320,
|
||||||
|
centerY: 180,
|
||||||
|
distancePx: 12,
|
||||||
|
}}
|
||||||
|
nodes={[
|
||||||
|
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||||
|
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestHandlersRef.current?.onConnectStart?.(
|
||||||
|
{} as MouseEvent,
|
||||||
|
{
|
||||||
|
nodeId: "node-source",
|
||||||
|
handleId: null,
|
||||||
|
handleType: "source",
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
latestHandlersRef.current?.onConnectEnd(
|
||||||
|
{ clientX: 400, clientY: 260 } as MouseEvent,
|
||||||
|
{
|
||||||
|
isValid: false,
|
||||||
|
from: { x: 0, y: 0 },
|
||||||
|
fromNode: { id: "node-source", type: "image" },
|
||||||
|
fromHandle: { id: null, type: "source" },
|
||||||
|
fromPosition: null,
|
||||||
|
to: { x: 400, y: 260 },
|
||||||
|
toHandle: null,
|
||||||
|
toNode: null,
|
||||||
|
toPosition: null,
|
||||||
|
pointer: null,
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
|
||||||
|
canvasId: "canvas-1",
|
||||||
|
sourceNodeId: "node-source",
|
||||||
|
targetNodeId: "node-target",
|
||||||
|
sourceHandle: undefined,
|
||||||
|
targetHandle: "base",
|
||||||
|
});
|
||||||
|
expect(latestMagnetTargetRef.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid active magnet target and clears transient state", async () => {
|
||||||
|
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||||
|
const showConnectionRejectedToast = vi.fn();
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<HookHarness
|
||||||
|
helperResult={null}
|
||||||
|
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||||
|
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||||
|
initialMagnetTarget={{
|
||||||
|
nodeId: "node-source",
|
||||||
|
handleType: "target",
|
||||||
|
centerX: 100,
|
||||||
|
centerY: 100,
|
||||||
|
distancePx: 10,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestHandlersRef.current?.onConnectStart?.(
|
||||||
|
{} as MouseEvent,
|
||||||
|
{
|
||||||
|
nodeId: "node-source",
|
||||||
|
handleId: null,
|
||||||
|
handleType: "source",
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
latestHandlersRef.current?.onConnectEnd(
|
||||||
|
{ clientX: 120, clientY: 120 } as MouseEvent,
|
||||||
|
{
|
||||||
|
isValid: false,
|
||||||
|
from: { x: 0, y: 0 },
|
||||||
|
fromNode: { id: "node-source", type: "image" },
|
||||||
|
fromHandle: { id: null, type: "source" },
|
||||||
|
fromPosition: null,
|
||||||
|
to: { x: 120, y: 120 },
|
||||||
|
toHandle: null,
|
||||||
|
toNode: null,
|
||||||
|
toPosition: null,
|
||||||
|
pointer: null,
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||||
|
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||||
|
expect(latestMagnetTargetRef.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears transient magnet state when dropping on background opens menu", async () => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<HookHarness
|
||||||
|
helperResult={null}
|
||||||
|
initialMagnetTarget={{
|
||||||
|
nodeId: "node-target",
|
||||||
|
handleType: "target",
|
||||||
|
centerX: 200,
|
||||||
|
centerY: 220,
|
||||||
|
distancePx: 14,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestHandlersRef.current?.onConnectStart?.(
|
||||||
|
{} as MouseEvent,
|
||||||
|
{
|
||||||
|
nodeId: "node-source",
|
||||||
|
handleId: null,
|
||||||
|
handleType: "source",
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestHandlersRef.current?.onConnectEnd(
|
||||||
|
{ clientX: 500, clientY: 460 } as MouseEvent,
|
||||||
|
{
|
||||||
|
isValid: false,
|
||||||
|
from: { x: 0, y: 0 },
|
||||||
|
fromNode: { id: "node-source", type: "image" },
|
||||||
|
fromHandle: { id: null, type: "source" },
|
||||||
|
fromPosition: null,
|
||||||
|
to: { x: 500, y: 460 },
|
||||||
|
toHandle: null,
|
||||||
|
toNode: null,
|
||||||
|
toPosition: null,
|
||||||
|
pointer: null,
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latestHandlersRef.current?.connectionDropMenu).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
screenX: 500,
|
||||||
|
screenY: 460,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(latestMagnetTargetRef.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears transient magnet state when reconnect drag ends", async () => {
|
||||||
|
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<HookHarness
|
||||||
|
helperResult={null}
|
||||||
|
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||||
|
edges={[
|
||||||
|
{
|
||||||
|
id: "edge-1",
|
||||||
|
source: "node-source",
|
||||||
|
target: "node-target",
|
||||||
|
targetHandle: "base",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
nodes={[
|
||||||
|
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||||
|
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||||
|
]}
|
||||||
|
initialMagnetTarget={{
|
||||||
|
nodeId: "node-target",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "overlay",
|
||||||
|
centerX: 300,
|
||||||
|
centerY: 180,
|
||||||
|
distancePx: 11,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldEdge = {
|
||||||
|
id: "edge-1",
|
||||||
|
source: "node-source",
|
||||||
|
target: "node-target",
|
||||||
|
targetHandle: "base",
|
||||||
|
} as RFEdge;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
latestHandlersRef.current?.onReconnectStart();
|
||||||
|
latestHandlersRef.current?.onReconnect(oldEdge, {
|
||||||
|
source: "node-source",
|
||||||
|
target: "node-target",
|
||||||
|
sourceHandle: null,
|
||||||
|
targetHandle: "overlay",
|
||||||
|
});
|
||||||
|
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runCreateEdgeMutation).toHaveBeenCalled();
|
||||||
|
expect(latestMagnetTargetRef.current).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -699,6 +699,7 @@ describe("favorite retention in strict local node flows", () => {
|
|||||||
vi.doMock("@xyflow/react", () => ({
|
vi.doMock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const importedModule = (await import(modulePath)) as {
|
const importedModule = (await import(modulePath)) as {
|
||||||
|
|||||||
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,
|
getSourceImageFromGraph,
|
||||||
} from "@/lib/canvas-render-preview";
|
} from "@/lib/canvas-render-preview";
|
||||||
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
|
import { resolveCanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
|
||||||
|
|
||||||
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||||
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||||
@@ -421,17 +422,7 @@ export function resolveDroppedConnectionTarget(args: {
|
|||||||
? []
|
? []
|
||||||
: document.elementsFromPoint(args.point.x, args.point.y);
|
: document.elementsFromPoint(args.point.x, args.point.y);
|
||||||
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
|
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
|
||||||
if (!nodeElement) {
|
if (nodeElement) {
|
||||||
logCanvasConnectionDebug("drop-target:node-missed", {
|
|
||||||
point: args.point,
|
|
||||||
fromNodeId: args.fromNodeId,
|
|
||||||
fromHandleId: args.fromHandleId ?? null,
|
|
||||||
fromHandleType: args.fromHandleType,
|
|
||||||
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetNodeId = nodeElement.dataset.id;
|
const targetNodeId = nodeElement.dataset.id;
|
||||||
if (!targetNodeId) {
|
if (!targetNodeId) {
|
||||||
logCanvasConnectionDebug("drop-target:node-missing-data-id", {
|
logCanvasConnectionDebug("drop-target:node-missing-data-id", {
|
||||||
@@ -508,6 +499,65 @@ export function resolveDroppedConnectionTarget(args: {
|
|||||||
resolvedConnection: droppedConnection,
|
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;
|
return droppedConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type UseCanvasReconnectHandlersParams = {
|
|||||||
nextOtherEdgeHandle: "base" | "overlay";
|
nextOtherEdgeHandle: "base" | "overlay";
|
||||||
} | null;
|
} | null;
|
||||||
onInvalidConnection?: (message: string) => void;
|
onInvalidConnection?: (message: string) => void;
|
||||||
|
clearActiveMagnetTarget?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCanvasReconnectHandlers({
|
export function useCanvasReconnectHandlers({
|
||||||
@@ -52,6 +53,7 @@ export function useCanvasReconnectHandlers({
|
|||||||
validateConnection,
|
validateConnection,
|
||||||
resolveMixerSwapReconnect,
|
resolveMixerSwapReconnect,
|
||||||
onInvalidConnection,
|
onInvalidConnection,
|
||||||
|
clearActiveMagnetTarget,
|
||||||
}: UseCanvasReconnectHandlersParams): {
|
}: UseCanvasReconnectHandlersParams): {
|
||||||
onReconnectStart: () => void;
|
onReconnectStart: () => void;
|
||||||
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||||
@@ -72,10 +74,11 @@ export function useCanvasReconnectHandlers({
|
|||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const onReconnectStart = useCallback(() => {
|
const onReconnectStart = useCallback(() => {
|
||||||
|
clearActiveMagnetTarget?.();
|
||||||
edgeReconnectSuccessful.current = false;
|
edgeReconnectSuccessful.current = false;
|
||||||
isReconnectDragActiveRef.current = true;
|
isReconnectDragActiveRef.current = true;
|
||||||
pendingReconnectRef.current = null;
|
pendingReconnectRef.current = null;
|
||||||
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
}, [clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
||||||
|
|
||||||
const onReconnect = useCallback(
|
const onReconnect = useCallback(
|
||||||
(oldEdge: RFEdge, newConnection: Connection) => {
|
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||||
@@ -201,11 +204,13 @@ export function useCanvasReconnectHandlers({
|
|||||||
|
|
||||||
edgeReconnectSuccessful.current = true;
|
edgeReconnectSuccessful.current = true;
|
||||||
} finally {
|
} finally {
|
||||||
|
clearActiveMagnetTarget?.();
|
||||||
isReconnectDragActiveRef.current = false;
|
isReconnectDragActiveRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
canvasId,
|
canvasId,
|
||||||
|
clearActiveMagnetTarget,
|
||||||
edgeReconnectSuccessful,
|
edgeReconnectSuccessful,
|
||||||
isReconnectDragActiveRef,
|
isReconnectDragActiveRef,
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
|
|||||||
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
|
||||||
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
|
||||||
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
||||||
|
import { HANDLE_GLOW_RADIUS_PX } from "./canvas-connection-magnetism";
|
||||||
|
import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context";
|
||||||
|
|
||||||
interface CanvasInnerProps {
|
interface CanvasInnerProps {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -675,6 +677,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
panOnDrag={flowPanOnDrag}
|
panOnDrag={flowPanOnDrag}
|
||||||
selectionOnDrag={flowSelectionOnDrag}
|
selectionOnDrag={flowSelectionOnDrag}
|
||||||
panActivationKeyCode="Space"
|
panActivationKeyCode="Space"
|
||||||
|
connectionRadius={HANDLE_GLOW_RADIUS_PX}
|
||||||
|
reconnectRadius={24}
|
||||||
|
edgesReconnectable
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -709,7 +714,9 @@ interface CanvasProps {
|
|||||||
export default function Canvas({ canvasId }: CanvasProps) {
|
export default function Canvas({ canvasId }: CanvasProps) {
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
|
<CanvasConnectionMagnetismProvider>
|
||||||
<CanvasInner canvasId={canvasId} />
|
<CanvasInner canvasId={canvasId} />
|
||||||
|
</CanvasConnectionMagnetismProvider>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,43 @@ import {
|
|||||||
getSmoothStepPath,
|
getSmoothStepPath,
|
||||||
getStraightPath,
|
getStraightPath,
|
||||||
type ConnectionLineComponentProps,
|
type ConnectionLineComponentProps,
|
||||||
|
useConnection,
|
||||||
|
useReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { connectionLineAccentRgb } from "@/lib/canvas-utils";
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
HANDLE_SNAP_RADIUS_PX,
|
||||||
|
resolveCanvasGlowStrength,
|
||||||
|
resolveCanvasMagnetTarget,
|
||||||
|
} from "@/components/canvas/canvas-connection-magnetism";
|
||||||
|
import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context";
|
||||||
|
import {
|
||||||
|
connectionLineAccentRgb,
|
||||||
|
connectionLineGlowFilter,
|
||||||
|
type EdgeGlowColorMode,
|
||||||
|
} from "@/lib/canvas-utils";
|
||||||
|
|
||||||
|
function hasSameMagnetTarget(
|
||||||
|
a: Parameters<ReturnType<typeof useCanvasConnectionMagnetism>["setActiveTarget"]>[0],
|
||||||
|
b: Parameters<ReturnType<typeof useCanvasConnectionMagnetism>["setActiveTarget"]>[0],
|
||||||
|
): boolean {
|
||||||
|
if (a === b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!a || !b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
a.nodeId === b.nodeId &&
|
||||||
|
a.handleId === b.handleId &&
|
||||||
|
a.handleType === b.handleType &&
|
||||||
|
a.centerX === b.centerX &&
|
||||||
|
a.centerY === b.centerY &&
|
||||||
|
a.distancePx === b.distancePx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CustomConnectionLine({
|
export default function CustomConnectionLine({
|
||||||
connectionLineType,
|
connectionLineType,
|
||||||
@@ -21,13 +56,74 @@ export default function CustomConnectionLine({
|
|||||||
fromPosition,
|
fromPosition,
|
||||||
toPosition,
|
toPosition,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
|
pointer,
|
||||||
}: ConnectionLineComponentProps) {
|
}: ConnectionLineComponentProps) {
|
||||||
|
const { getNodes, getEdges, screenToFlowPosition } = useReactFlow();
|
||||||
|
const connection = useConnection();
|
||||||
|
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||||
|
const fromHandleId = fromHandle?.id;
|
||||||
|
const fromNodeId = fromNode?.id;
|
||||||
|
|
||||||
|
const connectionFromHandleType =
|
||||||
|
connection.fromHandle?.type === "source" || connection.fromHandle?.type === "target"
|
||||||
|
? connection.fromHandle.type
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const fromHandleType =
|
||||||
|
fromHandle?.type === "source" || fromHandle?.type === "target"
|
||||||
|
? fromHandle.type
|
||||||
|
: connectionFromHandleType ?? "source";
|
||||||
|
|
||||||
|
const resolvedMagnetTarget = useMemo(() => {
|
||||||
|
if (!fromHandleType || !fromNodeId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const magnetPoint =
|
||||||
|
pointer && Number.isFinite(pointer.x) && Number.isFinite(pointer.y)
|
||||||
|
? { x: pointer.x, y: pointer.y }
|
||||||
|
: { x: toX, y: toY };
|
||||||
|
|
||||||
|
return resolveCanvasMagnetTarget({
|
||||||
|
point: magnetPoint,
|
||||||
|
fromNodeId,
|
||||||
|
fromHandleId: fromHandleId ?? undefined,
|
||||||
|
fromHandleType,
|
||||||
|
nodes: getNodes(),
|
||||||
|
edges: getEdges(),
|
||||||
|
});
|
||||||
|
}, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, pointer, toX, toY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTarget(resolvedMagnetTarget);
|
||||||
|
}, [activeTarget, resolvedMagnetTarget, setActiveTarget]);
|
||||||
|
|
||||||
|
const magnetTarget = activeTarget ?? resolvedMagnetTarget;
|
||||||
|
const glowStrength = magnetTarget
|
||||||
|
? resolveCanvasGlowStrength({
|
||||||
|
distancePx: magnetTarget.distancePx,
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
|
const snappedTarget =
|
||||||
|
magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX
|
||||||
|
? magnetTarget
|
||||||
|
: null;
|
||||||
|
const snappedFlowPoint =
|
||||||
|
snappedTarget === null
|
||||||
|
? null
|
||||||
|
: screenToFlowPosition({ x: snappedTarget.centerX, y: snappedTarget.centerY });
|
||||||
|
const targetX = snappedFlowPoint?.x ?? toX;
|
||||||
|
const targetY = snappedFlowPoint?.y ?? toY;
|
||||||
|
|
||||||
const pathParams = {
|
const pathParams = {
|
||||||
sourceX: fromX,
|
sourceX: fromX,
|
||||||
sourceY: fromY,
|
sourceY: fromY,
|
||||||
sourcePosition: fromPosition,
|
sourcePosition: fromPosition,
|
||||||
targetX: toX,
|
targetX,
|
||||||
targetY: toY,
|
targetY,
|
||||||
targetPosition: toPosition,
|
targetPosition: toPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,8 +148,19 @@ export default function CustomConnectionLine({
|
|||||||
[path] = getStraightPath(pathParams);
|
[path] = getStraightPath(pathParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id);
|
const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandleId);
|
||||||
const opacity = connectionStatus === "invalid" ? 0.45 : 1;
|
const opacity = connectionStatus === "invalid" ? 0.45 : 1;
|
||||||
|
const colorMode: EdgeGlowColorMode =
|
||||||
|
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
const strokeWidth = 2.5 + glowStrength * 0.75;
|
||||||
|
const filter = connectionLineGlowFilter({
|
||||||
|
nodeType: fromNode.type,
|
||||||
|
handleId: fromHandleId,
|
||||||
|
strength: glowStrength,
|
||||||
|
colorMode,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
@@ -62,9 +169,10 @@ export default function CustomConnectionLine({
|
|||||||
className="ls-connection-line-marching"
|
className="ls-connection-line-marching"
|
||||||
style={{
|
style={{
|
||||||
stroke: `rgb(${r}, ${g}, ${b})`,
|
stroke: `rgb(${r}, ${g}, ${b})`,
|
||||||
strokeWidth: 2.5,
|
strokeWidth,
|
||||||
strokeLinecap: "round",
|
strokeLinecap: "round",
|
||||||
strokeDasharray: "10 8",
|
strokeDasharray: "10 8",
|
||||||
|
filter,
|
||||||
opacity,
|
opacity,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Bot } from "lucide-react";
|
import { Bot } from "lucide-react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useAction } from "convex/react";
|
import { useAction } from "convex/react";
|
||||||
import type { FunctionReference } from "convex/server";
|
import type { FunctionReference } from "convex/server";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type AgentNodeData = {
|
type AgentNodeData = {
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
@@ -466,13 +467,17 @@ export default function AgentNode({ id, data, selected }: NodeProps<AgentNodeTyp
|
|||||||
statusMessage={nodeData._statusMessage}
|
statusMessage={nodeData._statusMessage}
|
||||||
className="min-w-[300px] border-amber-500/30"
|
className="min-w-[300px] border-amber-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="agent"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="agent-in"
|
id="agent-in"
|
||||||
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
|
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
|
||||||
/>
|
/>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="agent"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
|
className="!h-3 !w-3 !bg-amber-500 !border-2 !border-background"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type AgentOutputNodeData = {
|
type AgentOutputNodeData = {
|
||||||
isSkeleton?: boolean;
|
isSkeleton?: boolean;
|
||||||
@@ -186,7 +187,7 @@ function partitionSections(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
|
export default function AgentOutputNode({ id, data, selected }: NodeProps<AgentOutputNodeType>) {
|
||||||
const t = useTranslations("agentOutputNode");
|
const t = useTranslations("agentOutputNode");
|
||||||
const nodeData = data as AgentOutputNodeData;
|
const nodeData = data as AgentOutputNodeData;
|
||||||
const isSkeleton = nodeData.isSkeleton === true;
|
const isSkeleton = nodeData.isSkeleton === true;
|
||||||
@@ -240,7 +241,9 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
|||||||
statusMessage={nodeData._statusMessage}
|
statusMessage={nodeData._statusMessage}
|
||||||
className={`min-w-[300px] border-amber-500/30 ${isSkeleton ? "opacity-80" : ""}`}
|
className={`min-w-[300px] border-amber-500/30 ${isSkeleton ? "opacity-80" : ""}`}
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="agent-output"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="agent-output-in"
|
id="agent-output-in"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
|
import { Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
|
||||||
import { useAction } from "convex/react";
|
import { useAction } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type AiImageNodeData = {
|
type AiImageNodeData = {
|
||||||
storageId?: string;
|
storageId?: string;
|
||||||
@@ -194,7 +195,9 @@ export default function AiImageNode({
|
|||||||
]}
|
]}
|
||||||
className="flex h-full w-full min-h-0 min-w-0 flex-col"
|
className="flex h-full w-full min-h-0 min-w-0 flex-col"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="ai-image"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="prompt-in"
|
id="prompt-in"
|
||||||
@@ -331,7 +334,9 @@ export default function AiImageNode({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="ai-image"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="image-out"
|
id="image-out"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAction } from "convex/react";
|
|||||||
import type { FunctionReference } from "convex/server";
|
import type { FunctionReference } from "convex/server";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { AlertCircle, Download, Loader2, RefreshCw, Video } from "lucide-react";
|
import { AlertCircle, Download, Loader2, RefreshCw, Video } from "lucide-react";
|
||||||
import { Handle, Position, useReactFlow, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, useReactFlow, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -14,6 +14,7 @@ import { classifyError } from "@/lib/ai-errors";
|
|||||||
import { getVideoModel, type VideoModelDurationSeconds } from "@/lib/ai-video-models";
|
import { getVideoModel, type VideoModelDurationSeconds } from "@/lib/ai-video-models";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type AiVideoNodeData = {
|
type AiVideoNodeData = {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
@@ -160,7 +161,9 @@ export default function AiVideoNode({ id, data, selected }: NodeProps<AiVideoNod
|
|||||||
statusMessage={nodeData._statusMessage}
|
statusMessage={nodeData._statusMessage}
|
||||||
className="flex h-full w-full min-h-0 min-w-0 flex-col"
|
className="flex h-full w-full min-h-0 min-w-0 flex-col"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="ai-video"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="video-in"
|
id="video-in"
|
||||||
@@ -240,7 +243,9 @@ export default function AiVideoNode({ id, data, selected }: NodeProps<AiVideoNod
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="ai-video"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="video-out"
|
id="video-out"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, useStore, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { ExternalLink, ImageIcon } from "lucide-react";
|
import { ExternalLink, ImageIcon } from "lucide-react";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type AssetNodeData = {
|
type AssetNodeData = {
|
||||||
assetId?: number;
|
assetId?: number;
|
||||||
@@ -152,7 +153,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
status={data._status}
|
status={data._status}
|
||||||
statusMessage={data._statusMessage}
|
statusMessage={data._statusMessage}
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="asset"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||||
@@ -273,7 +276,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="asset"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Palette } from "lucide-react";
|
import { Palette } from "lucide-react";
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
|||||||
import { COLOR_PRESETS } from "@/lib/image-pipeline/presets";
|
import { COLOR_PRESETS } from "@/lib/image-pipeline/presets";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type ColorAdjustNodeData = ColorAdjustData & {
|
type ColorAdjustNodeData = ColorAdjustData & {
|
||||||
_status?: string;
|
_status?: string;
|
||||||
@@ -191,7 +192,9 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
statusMessage={data._statusMessage}
|
statusMessage={data._statusMessage}
|
||||||
className="min-w-[300px] border-cyan-500/30"
|
className="min-w-[300px] border-cyan-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="color-adjust"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"
|
||||||
@@ -268,7 +271,9 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="color-adjust"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
import { Position, type NodeProps } from "@xyflow/react";
|
||||||
import { ImageIcon } from "lucide-react";
|
import { ImageIcon } from "lucide-react";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
import CompareSurface from "./compare-surface";
|
import CompareSurface from "./compare-surface";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
resolveMixerPreviewFromGraph,
|
resolveMixerPreviewFromGraph,
|
||||||
type MixerPreviewState,
|
type MixerPreviewState,
|
||||||
} from "@/lib/canvas-mixer-preview";
|
} from "@/lib/canvas-mixer-preview";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
interface CompareNodeData {
|
interface CompareNodeData {
|
||||||
leftUrl?: string;
|
leftUrl?: string;
|
||||||
@@ -242,21 +243,27 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
|
<BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="compare"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="left"
|
id="left"
|
||||||
style={{ top: "35%" }}
|
style={{ top: "35%" }}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-blue-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-blue-500"
|
||||||
/>
|
/>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="compare"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="right"
|
id="right"
|
||||||
style={{ top: "55%" }}
|
style={{ top: "55%" }}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||||
/>
|
/>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="compare"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="compare-out"
|
id="compare-out"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react";
|
import { useCallback, useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { Crop } from "lucide-react";
|
import { Crop } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type CropNodeViewData = CropNodeData & {
|
type CropNodeViewData = CropNodeData & {
|
||||||
_status?: string;
|
_status?: string;
|
||||||
@@ -400,7 +401,9 @@ export default function CropNode({ id, data, selected, width }: NodeProps<CropNo
|
|||||||
statusMessage={data._statusMessage}
|
statusMessage={data._statusMessage}
|
||||||
className="min-w-[320px] border-violet-500/30"
|
className="min-w-[320px] border-violet-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="crop"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
|
||||||
@@ -735,7 +738,9 @@ export default function CropNode({ id, data, selected, width }: NodeProps<CropNo
|
|||||||
{error ? <p className="text-[11px] text-destructive">{error}</p> : null}
|
{error ? <p className="text-[11px] text-destructive">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="crop"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { TrendingUp } from "lucide-react";
|
import { TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
|||||||
import { CURVE_PRESETS } from "@/lib/image-pipeline/presets";
|
import { CURVE_PRESETS } from "@/lib/image-pipeline/presets";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type CurvesNodeData = CurvesData & {
|
type CurvesNodeData = CurvesData & {
|
||||||
_status?: string;
|
_status?: string;
|
||||||
@@ -163,7 +164,9 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
|||||||
statusMessage={data._statusMessage}
|
statusMessage={data._statusMessage}
|
||||||
className="min-w-[300px] border-emerald-500/30"
|
className="min-w-[300px] border-emerald-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="curves"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||||
@@ -237,7 +240,9 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="curves"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Focus } from "lucide-react";
|
import { Focus } from "lucide-react";
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
|||||||
import { DETAIL_PRESETS } from "@/lib/image-pipeline/presets";
|
import { DETAIL_PRESETS } from "@/lib/image-pipeline/presets";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type DetailAdjustNodeData = DetailAdjustData & {
|
type DetailAdjustNodeData = DetailAdjustData & {
|
||||||
_status?: string;
|
_status?: string;
|
||||||
@@ -202,7 +203,9 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
|||||||
statusMessage={data._statusMessage}
|
statusMessage={data._statusMessage}
|
||||||
className="min-w-[300px] border-indigo-500/30"
|
className="min-w-[300px] border-indigo-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="detail-adjust"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"
|
||||||
@@ -286,7 +289,9 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="detail-adjust"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
import { Position, type NodeProps } from "@xyflow/react";
|
||||||
import { useAction } from "convex/react";
|
import { useAction } from "convex/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
@@ -11,6 +11,7 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
|||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
interface FrameNodeData {
|
interface FrameNodeData {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -125,13 +126,17 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
|
|||||||
|
|
||||||
<div className="nodrag h-full w-full" />
|
<div className="nodrag h-full w-full" />
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="frame"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="frame-in"
|
id="frame-in"
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-orange-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-orange-500"
|
||||||
/>
|
/>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="frame"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="frame-out"
|
id="frame-out"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
import { Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type GroupNodeData = {
|
type GroupNodeData = {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -47,7 +48,9 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
|||||||
selected={selected}
|
selected={selected}
|
||||||
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
|
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="group"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
|
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
|
||||||
@@ -71,7 +74,9 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="group"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
|
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
import { Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
import { Maximize2, X } from "lucide-react";
|
import { Maximize2, X } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
getImageDimensions,
|
getImageDimensions,
|
||||||
} from "@/components/canvas/canvas-media-utils";
|
} from "@/components/canvas/canvas-media-utils";
|
||||||
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
const ALLOWED_IMAGE_TYPES = new Set([
|
const ALLOWED_IMAGE_TYPES = new Set([
|
||||||
"image/png",
|
"image/png",
|
||||||
@@ -508,7 +509,9 @@ export default function ImageNode({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="image"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||||
@@ -609,7 +612,9 @@ export default function ImageNode({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="image"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Sun } from "lucide-react";
|
import { Sun } from "lucide-react";
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
|||||||
import { LIGHT_PRESETS } from "@/lib/image-pipeline/presets";
|
import { LIGHT_PRESETS } from "@/lib/image-pipeline/presets";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type LightAdjustNodeData = LightAdjustData & {
|
type LightAdjustNodeData = LightAdjustData & {
|
||||||
_status?: string;
|
_status?: string;
|
||||||
@@ -213,7 +214,9 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
statusMessage={data._statusMessage}
|
statusMessage={data._statusMessage}
|
||||||
className="min-w-[300px] border-amber-500/30"
|
className="min-w-[300px] border-amber-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="light-adjust"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"
|
||||||
@@ -292,7 +295,9 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="light-adjust"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
import { Position, type NodeProps } from "@xyflow/react";
|
||||||
|
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type MixerBlendMode,
|
type MixerBlendMode,
|
||||||
} from "@/lib/canvas-mixer-preview";
|
} from "@/lib/canvas-mixer-preview";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"];
|
const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"];
|
||||||
|
|
||||||
@@ -56,21 +57,27 @@ export default function MixerNode({ id, data, selected }: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper nodeType="mixer" selected={selected} className="p-0">
|
<BaseNodeWrapper nodeType="mixer" selected={selected} className="p-0">
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="mixer"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="base"
|
id="base"
|
||||||
style={{ top: "35%" }}
|
style={{ top: "35%" }}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
||||||
/>
|
/>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="mixer"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="overlay"
|
id="overlay"
|
||||||
style={{ top: "58%" }}
|
style={{ top: "58%" }}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-pink-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-pink-500"
|
||||||
/>
|
/>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="mixer"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="mixer-out"
|
id="mixer-out"
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
import { Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type NoteNodeData = {
|
type NoteNodeData = {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -53,7 +54,9 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper nodeType="note" selected={selected} className="p-3">
|
<BaseNodeWrapper nodeType="note" selected={selected} className="p-3">
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="note"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||||
@@ -85,7 +88,9 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="note"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
Position,
|
Position,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
useStore,
|
useStore,
|
||||||
@@ -45,6 +44,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { classifyError } from "@/lib/ai-errors";
|
import { classifyError } from "@/lib/ai-errors";
|
||||||
import { normalizePublicTier } from "@/lib/tier-credits";
|
import { normalizePublicTier } from "@/lib/tier-credits";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type PromptNodeData = {
|
type PromptNodeData = {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
@@ -353,7 +353,9 @@ export default function PromptNode({
|
|||||||
statusMessage={nodeData._statusMessage}
|
statusMessage={nodeData._statusMessage}
|
||||||
className="min-w-[240px] border-violet-500/30"
|
className="min-w-[240px] border-violet-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="prompt"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="image-in"
|
id="image-in"
|
||||||
@@ -489,7 +491,9 @@ export default function PromptNode({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="prompt"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="prompt-out"
|
id="prompt-out"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { AlertCircle, ArrowDown, CheckCircle2, CloudUpload, Loader2, Maximize2, X } from "lucide-react";
|
import { AlertCircle, ArrowDown, CheckCircle2, CloudUpload, Loader2, Maximize2, X } from "lucide-react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type RenderResolutionOption = "original" | "2x" | "custom";
|
type RenderResolutionOption = "original" | "2x" | "custom";
|
||||||
type RenderFormatOption = "png" | "jpeg" | "webp";
|
type RenderFormatOption = "png" | "jpeg" | "webp";
|
||||||
@@ -978,7 +979,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
]}
|
]}
|
||||||
className="flex h-full min-w-[280px] flex-col overflow-hidden border-sky-500/30"
|
className="flex h-full min-w-[280px] flex-col overflow-hidden border-sky-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="render"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
||||||
@@ -1273,7 +1276,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="render"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
Position,
|
Position,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type TextNodeData = {
|
type TextNodeData = {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -155,7 +155,9 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
|||||||
]}
|
]}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="text"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||||
@@ -190,7 +192,9 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="text"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Handle, Position, useStore, type NodeProps } from "@xyflow/react";
|
import { Position, useStore, type NodeProps } from "@xyflow/react";
|
||||||
import { useAction } from "convex/react";
|
import { useAction } from "convex/react";
|
||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type VideoNodeData = {
|
type VideoNodeData = {
|
||||||
canvasId?: string;
|
canvasId?: string;
|
||||||
@@ -150,7 +151,9 @@ export default function VideoNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper nodeType="video" selected={selected}>
|
<BaseNodeWrapper nodeType="video" selected={selected}>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="video"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||||
@@ -245,7 +248,9 @@ export default function VideoNode({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="video"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Handle, Position, useReactFlow, useStore, type Node, type NodeProps } from "@xyflow/react";
|
import { Position, useReactFlow, useStore, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useAction } from "convex/react";
|
import { useAction } from "convex/react";
|
||||||
import type { FunctionReference } from "convex/server";
|
import type { FunctionReference } from "convex/server";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
type VideoPromptNodeData = {
|
type VideoPromptNodeData = {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
@@ -300,7 +301,9 @@ export default function VideoPromptNode({
|
|||||||
statusMessage={nodeData._statusMessage}
|
statusMessage={nodeData._statusMessage}
|
||||||
className="min-w-[260px] border-violet-500/30"
|
className="min-w-[260px] border-violet-500/30"
|
||||||
>
|
>
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="video-prompt"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="video-prompt-in"
|
id="video-prompt-in"
|
||||||
@@ -407,7 +410,9 @@ export default function VideoPromptNode({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<CanvasHandle
|
||||||
|
nodeId={id}
|
||||||
|
nodeType="video-prompt"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="video-prompt-out"
|
id="video-prompt-out"
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
|
|||||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveCanvasMagnetTarget,
|
||||||
|
type CanvasMagnetTarget,
|
||||||
|
} from "./canvas-connection-magnetism";
|
||||||
import {
|
import {
|
||||||
getConnectEndClientPoint,
|
getConnectEndClientPoint,
|
||||||
hasHandleKey,
|
hasHandleKey,
|
||||||
@@ -24,6 +28,7 @@ import {
|
|||||||
validateCanvasConnectionByType,
|
validateCanvasConnectionByType,
|
||||||
validateCanvasEdgeSplit,
|
validateCanvasEdgeSplit,
|
||||||
} from "./canvas-connection-validation";
|
} from "./canvas-connection-validation";
|
||||||
|
import { useCanvasConnectionMagnetism } from "./canvas-connection-magnetism-context";
|
||||||
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
import { useCanvasReconnectHandlers } from "./canvas-reconnect";
|
||||||
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
|
||||||
|
|
||||||
@@ -122,6 +127,7 @@ export function useCanvasConnections({
|
|||||||
runSwapMixerInputsMutation,
|
runSwapMixerInputsMutation,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
}: UseCanvasConnectionsParams) {
|
}: UseCanvasConnectionsParams) {
|
||||||
|
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
|
||||||
const [connectionDropMenu, setConnectionDropMenu] =
|
const [connectionDropMenu, setConnectionDropMenu] =
|
||||||
useState<ConnectionDropMenuState | null>(null);
|
useState<ConnectionDropMenuState | null>(null);
|
||||||
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
||||||
@@ -133,17 +139,40 @@ export function useCanvasConnections({
|
|||||||
}, [connectionDropMenu]);
|
}, [connectionDropMenu]);
|
||||||
|
|
||||||
const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
|
const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
|
||||||
|
setActiveTarget(null);
|
||||||
isConnectDragActiveRef.current = true;
|
isConnectDragActiveRef.current = true;
|
||||||
logCanvasConnectionDebug("connect:start", {
|
logCanvasConnectionDebug("connect:start", {
|
||||||
nodeId: params.nodeId,
|
nodeId: params.nodeId,
|
||||||
handleId: params.handleId,
|
handleId: params.handleId,
|
||||||
handleType: params.handleType,
|
handleType: params.handleType,
|
||||||
});
|
});
|
||||||
}, []);
|
}, [setActiveTarget]);
|
||||||
|
|
||||||
|
const toDroppedConnectionFromMagnetTarget = useCallback(
|
||||||
|
(fromHandleType: "source" | "target", fromNodeId: string, fromHandleId: string | undefined, magnetTarget: CanvasMagnetTarget) => {
|
||||||
|
if (fromHandleType === "source") {
|
||||||
|
return {
|
||||||
|
sourceNodeId: fromNodeId,
|
||||||
|
targetNodeId: magnetTarget.nodeId,
|
||||||
|
sourceHandle: fromHandleId,
|
||||||
|
targetHandle: magnetTarget.handleId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceNodeId: magnetTarget.nodeId,
|
||||||
|
targetNodeId: fromNodeId,
|
||||||
|
sourceHandle: magnetTarget.handleId,
|
||||||
|
targetHandle: fromHandleId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
isConnectDragActiveRef.current = false;
|
isConnectDragActiveRef.current = false;
|
||||||
|
try {
|
||||||
const validationError = validateCanvasConnection(connection, nodes, edges);
|
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
logCanvasConnectionDebug("connect:invalid-direct", {
|
logCanvasConnectionDebug("connect:invalid-direct", {
|
||||||
@@ -181,8 +210,11 @@ export function useCanvasConnections({
|
|||||||
sourceHandle: connection.sourceHandle ?? undefined,
|
sourceHandle: connection.sourceHandle ?? undefined,
|
||||||
targetHandle: connection.targetHandle ?? undefined,
|
targetHandle: connection.targetHandle ?? undefined,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setActiveTarget(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
|
[canvasId, edges, nodes, runCreateEdgeMutation, setActiveTarget, showConnectionRejectedToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolveMixerSwapReconnect = useCallback(
|
const resolveMixerSwapReconnect = useCallback(
|
||||||
@@ -252,6 +284,7 @@ export function useCanvasConnections({
|
|||||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||||
(event, connectionState) => {
|
(event, connectionState) => {
|
||||||
if (!isConnectDragActiveRef.current) {
|
if (!isConnectDragActiveRef.current) {
|
||||||
|
setActiveTarget(null);
|
||||||
logCanvasConnectionDebug("connect:end-ignored", {
|
logCanvasConnectionDebug("connect:end-ignored", {
|
||||||
reason: "drag-not-active",
|
reason: "drag-not-active",
|
||||||
isValid: connectionState.isValid ?? null,
|
isValid: connectionState.isValid ?? null,
|
||||||
@@ -264,6 +297,7 @@ export function useCanvasConnections({
|
|||||||
}
|
}
|
||||||
|
|
||||||
isConnectDragActiveRef.current = false;
|
isConnectDragActiveRef.current = false;
|
||||||
|
try {
|
||||||
if (isReconnectDragActiveRef.current) {
|
if (isReconnectDragActiveRef.current) {
|
||||||
logCanvasConnectionDebug("connect:end-ignored", {
|
logCanvasConnectionDebug("connect:end-ignored", {
|
||||||
reason: "reconnect-active",
|
reason: "reconnect-active",
|
||||||
@@ -319,7 +353,7 @@ export function useCanvasConnections({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
||||||
const droppedConnection = resolveDroppedConnectionTarget({
|
let droppedConnection = resolveDroppedConnectionTarget({
|
||||||
point: pt,
|
point: pt,
|
||||||
fromNodeId: fromNode.id,
|
fromNodeId: fromNode.id,
|
||||||
fromHandleId: fromHandle.id ?? undefined,
|
fromHandleId: fromHandle.id ?? undefined,
|
||||||
@@ -328,6 +362,28 @@ export function useCanvasConnections({
|
|||||||
edges: edgesRef.current,
|
edges: edgesRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!droppedConnection) {
|
||||||
|
const fallbackMagnetTarget =
|
||||||
|
activeTarget ??
|
||||||
|
resolveCanvasMagnetTarget({
|
||||||
|
point: pt,
|
||||||
|
fromNodeId: fromNode.id,
|
||||||
|
fromHandleId: fromHandle.id ?? undefined,
|
||||||
|
fromHandleType: fromHandle.type,
|
||||||
|
nodes: nodesRef.current,
|
||||||
|
edges: edgesRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fallbackMagnetTarget) {
|
||||||
|
droppedConnection = toDroppedConnectionFromMagnetTarget(
|
||||||
|
fromHandle.type,
|
||||||
|
fromNode.id,
|
||||||
|
fromHandle.id ?? undefined,
|
||||||
|
fallbackMagnetTarget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logCanvasConnectionDebug("connect:end-drop-result", {
|
logCanvasConnectionDebug("connect:end-drop-result", {
|
||||||
point: pt,
|
point: pt,
|
||||||
flow,
|
flow,
|
||||||
@@ -445,6 +501,9 @@ export function useCanvasConnections({
|
|||||||
fromHandleId: fromHandle.id ?? undefined,
|
fromHandleId: fromHandle.id ?? undefined,
|
||||||
fromHandleType: fromHandle.type,
|
fromHandleType: fromHandle.type,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setActiveTarget(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
canvasId,
|
canvasId,
|
||||||
@@ -454,7 +513,10 @@ export function useCanvasConnections({
|
|||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
runSplitEdgeAtExistingNodeMutation,
|
runSplitEdgeAtExistingNodeMutation,
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
|
setActiveTarget,
|
||||||
showConnectionRejectedToast,
|
showConnectionRejectedToast,
|
||||||
|
activeTarget,
|
||||||
|
toDroppedConnectionFromMagnetTarget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -598,6 +660,9 @@ export function useCanvasConnections({
|
|||||||
onInvalidConnection: (reason) => {
|
onInvalidConnection: (reason) => {
|
||||||
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
|
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
|
||||||
},
|
},
|
||||||
|
clearActiveMagnetTarget: () => {
|
||||||
|
setActiveTarget(null);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ Alle Adapter-Funktionen zwischen Convex-Datenmodell und React Flow. Details in `
|
|||||||
- `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ (inkl. `video-prompt` und `ai-video`)
|
- `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ (inkl. `video-prompt` und `ai-video`)
|
||||||
- `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ (inkl. `video-prompt-out/in` und `video-out/in`)
|
- `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ (inkl. `video-prompt-out/in` und `video-out/in`)
|
||||||
- `SOURCE_NODE_GLOW_RGB` — Edge-Glow-Farben pro Source-Node-Typ (inkl. `video-prompt` und `ai-video`)
|
- `SOURCE_NODE_GLOW_RGB` — Edge-Glow-Farben pro Source-Node-Typ (inkl. `video-prompt` und `ai-video`)
|
||||||
|
- `canvasHandleAccentRgb`, `canvasHandleAccentColor`, `canvasHandleAccentColorWithAlpha` — gemeinsame Handle-Akzentfarben (inkl. Spezialfälle für `compare.left/right/compare-out` und `mixer.base/overlay/mixer-out`)
|
||||||
- `agent` ist als input-only Node enthalten (`NODE_HANDLE_MAP.agent = { target: "agent-in" }`)
|
- `agent` ist als input-only Node enthalten (`NODE_HANDLE_MAP.agent = { target: "agent-in" }`)
|
||||||
- `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung
|
- `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung
|
||||||
- `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen
|
- `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
|
|||||||
* Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente).
|
* Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente).
|
||||||
* Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad).
|
* Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad).
|
||||||
*/
|
*/
|
||||||
const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> = {
|
type RgbColor = readonly [number, number, number];
|
||||||
|
|
||||||
|
const SOURCE_NODE_GLOW_RGB: Record<string, RgbColor> = {
|
||||||
prompt: [139, 92, 246],
|
prompt: [139, 92, 246],
|
||||||
"video-prompt": [124, 58, 237],
|
"video-prompt": [124, 58, 237],
|
||||||
"ai-image": [139, 92, 246],
|
"ai-image": [139, 92, 246],
|
||||||
@@ -123,21 +125,158 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
|
|||||||
render: [14, 165, 233],
|
render: [14, 165, 233],
|
||||||
agent: [245, 158, 11],
|
agent: [245, 158, 11],
|
||||||
"agent-output": [245, 158, 11],
|
"agent-output": [245, 158, 11],
|
||||||
|
mixer: [100, 116, 139],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
|
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
|
||||||
const COMPARE_HANDLE_CONNECTION_RGB: Record<
|
const COMPARE_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
|
||||||
string,
|
|
||||||
readonly [number, number, number]
|
|
||||||
> = {
|
|
||||||
left: [59, 130, 246],
|
left: [59, 130, 246],
|
||||||
right: [16, 185, 129],
|
right: [16, 185, 129],
|
||||||
"compare-out": [100, 116, 139],
|
"compare-out": [100, 116, 139],
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
|
const MIXER_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
|
||||||
13, 148, 136,
|
base: [14, 165, 233],
|
||||||
];
|
overlay: [236, 72, 153],
|
||||||
|
"mixer-out": [100, 116, 139],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_LINE_FALLBACK_RGB: RgbColor = [13, 148, 136];
|
||||||
|
|
||||||
|
export function canvasHandleAccentRgb(args: {
|
||||||
|
nodeType: string | undefined;
|
||||||
|
handleId?: string | null;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
}): RgbColor {
|
||||||
|
const nodeType = args.nodeType;
|
||||||
|
const handleId = args.handleId ?? undefined;
|
||||||
|
const handleType = args.handleType;
|
||||||
|
|
||||||
|
if (nodeType === "compare" && handleId) {
|
||||||
|
if (handleType === "target" && handleId === "compare-out") {
|
||||||
|
return SOURCE_NODE_GLOW_RGB.compare;
|
||||||
|
}
|
||||||
|
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
|
||||||
|
if (byHandle) {
|
||||||
|
return byHandle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeType === "mixer" && handleId) {
|
||||||
|
if (handleType === "target" && handleId === "mixer-out") {
|
||||||
|
return SOURCE_NODE_GLOW_RGB.mixer;
|
||||||
|
}
|
||||||
|
const byHandle = MIXER_HANDLE_CONNECTION_RGB[handleId];
|
||||||
|
if (byHandle) {
|
||||||
|
return byHandle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodeType) {
|
||||||
|
return CONNECTION_LINE_FALLBACK_RGB;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasHandleAccentColor(args: {
|
||||||
|
nodeType: string | undefined;
|
||||||
|
handleId?: string | null;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
}): string {
|
||||||
|
const [r, g, b] = canvasHandleAccentRgb(args);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasHandleAccentColorWithAlpha(
|
||||||
|
args: {
|
||||||
|
nodeType: string | undefined;
|
||||||
|
handleId?: string | null;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
},
|
||||||
|
alpha: number,
|
||||||
|
): string {
|
||||||
|
const [r, g, b] = canvasHandleAccentRgb(args);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampUnit(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (value <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (value >= 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(min: number, max: number, t: number): number {
|
||||||
|
return min + (max - min) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasHandleGlowShadow(args: {
|
||||||
|
nodeType: string | undefined;
|
||||||
|
handleId?: string | null;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
strength: number;
|
||||||
|
colorMode: EdgeGlowColorMode;
|
||||||
|
}): string | undefined {
|
||||||
|
const strength = clampUnit(args.strength);
|
||||||
|
if (strength <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [r, g, b] = canvasHandleAccentRgb(args);
|
||||||
|
const isDark = args.colorMode === "dark";
|
||||||
|
|
||||||
|
const ringAlpha = isDark
|
||||||
|
? lerp(0.08, 0.3, strength)
|
||||||
|
: lerp(0.06, 0.2, strength);
|
||||||
|
const glowAlpha = isDark
|
||||||
|
? lerp(0.12, 0.58, strength)
|
||||||
|
: lerp(0.08, 0.34, strength);
|
||||||
|
const ringSize = isDark
|
||||||
|
? lerp(1.8, 6.4, strength)
|
||||||
|
: lerp(1.5, 5.2, strength);
|
||||||
|
const glowSize = isDark
|
||||||
|
? lerp(4.5, 15, strength)
|
||||||
|
: lerp(3.5, 12, strength);
|
||||||
|
|
||||||
|
return `0 0 0 ${ringSize.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${ringAlpha.toFixed(3)}), 0 0 ${glowSize.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${glowAlpha.toFixed(3)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectionLineGlowFilter(args: {
|
||||||
|
nodeType: string | undefined;
|
||||||
|
handleId: string | null | undefined;
|
||||||
|
strength: number;
|
||||||
|
colorMode: EdgeGlowColorMode;
|
||||||
|
}): string | undefined {
|
||||||
|
const strength = clampUnit(args.strength);
|
||||||
|
if (strength <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [r, g, b] = connectionLineAccentRgb(args.nodeType, args.handleId);
|
||||||
|
const isDark = args.colorMode === "dark";
|
||||||
|
|
||||||
|
const innerAlpha = isDark
|
||||||
|
? lerp(0.22, 0.72, strength)
|
||||||
|
: lerp(0.12, 0.42, strength);
|
||||||
|
const outerAlpha = isDark
|
||||||
|
? lerp(0.12, 0.38, strength)
|
||||||
|
: lerp(0.06, 0.2, strength);
|
||||||
|
const innerBlur = isDark
|
||||||
|
? lerp(2.4, 4.2, strength)
|
||||||
|
: lerp(2, 3.4, strength);
|
||||||
|
const outerBlur = isDark
|
||||||
|
? lerp(5.4, 9.8, strength)
|
||||||
|
: lerp(4.6, 7.8, strength);
|
||||||
|
|
||||||
|
return `drop-shadow(0 0 ${innerBlur.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${innerAlpha.toFixed(3)})) drop-shadow(0 0 ${outerBlur.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${outerAlpha.toFixed(3)}))`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
|
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
|
||||||
@@ -145,13 +284,12 @@ const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
|
|||||||
export function connectionLineAccentRgb(
|
export function connectionLineAccentRgb(
|
||||||
nodeType: string | undefined,
|
nodeType: string | undefined,
|
||||||
handleId: string | null | undefined,
|
handleId: string | null | undefined,
|
||||||
): readonly [number, number, number] {
|
): RgbColor {
|
||||||
if (nodeType === "compare" && handleId) {
|
return canvasHandleAccentRgb({
|
||||||
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
|
nodeType,
|
||||||
if (byHandle) return byHandle;
|
handleId,
|
||||||
}
|
handleType: "source",
|
||||||
if (!nodeType) return CONNECTION_LINE_FALLBACK_RGB;
|
});
|
||||||
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgeGlowColorMode = "light" | "dark";
|
export type EdgeGlowColorMode = "light" | "dark";
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ vi.mock("next-intl", () => ({
|
|||||||
vi.mock("@xyflow/react", () => ({
|
vi.mock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import AgentNode from "@/components/canvas/nodes/agent-node";
|
import AgentNode from "@/components/canvas/nodes/agent-node";
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ vi.mock("@xyflow/react", () => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const translations: Record<string, string> = {
|
const translations: Record<string, string> = {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ vi.mock("@xyflow/react", () => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const translations: Record<string, string> = {
|
const translations: Record<string, string> = {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
|||||||
vi.mock("@xyflow/react", () => ({
|
vi.mock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
useReactFlow: () => ({
|
useReactFlow: () => ({
|
||||||
getEdges: mocks.getEdges,
|
getEdges: mocks.getEdges,
|
||||||
getNode: mocks.getNode,
|
getNode: mocks.getNode,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
vi.mock("@xyflow/react", () => ({
|
vi.mock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("next-intl", () => ({
|
vi.mock("next-intl", () => ({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const parameterSliderState = vi.hoisted(() => ({
|
|||||||
vi.mock("@xyflow/react", () => ({
|
vi.mock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("convex/react", () => ({
|
vi.mock("convex/react", () => ({
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
|||||||
vi.mock("@xyflow/react", () => ({
|
vi.mock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
|
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
|
||||||
selector({ edges: mocks.edges, nodes: mocks.nodes }),
|
selector({ edges: mocks.edges, nodes: mocks.nodes }),
|
||||||
useReactFlow: () => ({
|
useReactFlow: () => ({
|
||||||
|
|||||||
@@ -691,6 +691,7 @@ describe("preview histogram call sites", () => {
|
|||||||
vi.doMock("@xyflow/react", () => ({
|
vi.doMock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
vi.doMock("convex/react", () => ({
|
vi.doMock("convex/react", () => ({
|
||||||
useMutation: () => vi.fn(async () => undefined),
|
useMutation: () => vi.fn(async () => undefined),
|
||||||
@@ -754,6 +755,8 @@ describe("preview histogram call sites", () => {
|
|||||||
}));
|
}));
|
||||||
vi.doMock("@/lib/canvas-utils", () => ({
|
vi.doMock("@/lib/canvas-utils", () => ({
|
||||||
resolveMediaAspectRatio: () => null,
|
resolveMediaAspectRatio: () => null,
|
||||||
|
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
|
||||||
|
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
|
||||||
}));
|
}));
|
||||||
vi.doMock("@/lib/image-formats", () => ({
|
vi.doMock("@/lib/image-formats", () => ({
|
||||||
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
||||||
@@ -875,6 +878,7 @@ describe("preview histogram call sites", () => {
|
|||||||
vi.doMock("@xyflow/react", () => ({
|
vi.doMock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
vi.doMock("convex/react", () => ({
|
vi.doMock("convex/react", () => ({
|
||||||
useMutation: () => vi.fn(async () => undefined),
|
useMutation: () => vi.fn(async () => undefined),
|
||||||
@@ -935,6 +939,8 @@ describe("preview histogram call sites", () => {
|
|||||||
}));
|
}));
|
||||||
vi.doMock("@/lib/canvas-utils", () => ({
|
vi.doMock("@/lib/canvas-utils", () => ({
|
||||||
resolveMediaAspectRatio: () => null,
|
resolveMediaAspectRatio: () => null,
|
||||||
|
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
|
||||||
|
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
|
||||||
}));
|
}));
|
||||||
vi.doMock("@/lib/image-formats", () => ({
|
vi.doMock("@/lib/image-formats", () => ({
|
||||||
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
||||||
@@ -1063,6 +1069,7 @@ describe("preview histogram call sites", () => {
|
|||||||
vi.doMock("@xyflow/react", () => ({
|
vi.doMock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
}));
|
}));
|
||||||
vi.doMock("convex/react", () => ({
|
vi.doMock("convex/react", () => ({
|
||||||
useMutation: () => vi.fn(async () => undefined),
|
useMutation: () => vi.fn(async () => undefined),
|
||||||
@@ -1126,6 +1133,8 @@ describe("preview histogram call sites", () => {
|
|||||||
}));
|
}));
|
||||||
vi.doMock("@/lib/canvas-utils", () => ({
|
vi.doMock("@/lib/canvas-utils", () => ({
|
||||||
resolveMediaAspectRatio: () => null,
|
resolveMediaAspectRatio: () => null,
|
||||||
|
canvasHandleAccentColor: () => "rgb(13, 148, 136)",
|
||||||
|
canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)",
|
||||||
}));
|
}));
|
||||||
vi.doMock("@/lib/image-formats", () => ({
|
vi.doMock("@/lib/image-formats", () => ({
|
||||||
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
parseAspectRatioString: () => ({ w: 1, h: 1 }),
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
|||||||
vi.mock("@xyflow/react", () => ({
|
vi.mock("@xyflow/react", () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Left: "left", Right: "right" },
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => ({ inProgress: false }),
|
||||||
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
|
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
|
||||||
selector({ edges: mocks.edges, nodes: mocks.nodes }),
|
selector({ edges: mocks.edges, nodes: mocks.nodes }),
|
||||||
useReactFlow: () => ({
|
useReactFlow: () => ({
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export default defineConfig({
|
|||||||
"components/canvas/__tests__/use-canvas-edge-types.test.tsx",
|
"components/canvas/__tests__/use-canvas-edge-types.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
|
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
|
||||||
"components/canvas/__tests__/canvas-delete-handlers.test.tsx",
|
"components/canvas/__tests__/canvas-delete-handlers.test.tsx",
|
||||||
|
"components/canvas/__tests__/canvas-handle.test.tsx",
|
||||||
|
"components/canvas/__tests__/custom-connection-line.test.tsx",
|
||||||
"components/canvas/__tests__/canvas-media-utils.test.ts",
|
"components/canvas/__tests__/canvas-media-utils.test.ts",
|
||||||
"components/canvas/__tests__/base-node-wrapper.test.tsx",
|
"components/canvas/__tests__/base-node-wrapper.test.tsx",
|
||||||
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user