feat(canvas): finalize mixer reconnect swap and related updates
This commit is contained in:
@@ -49,11 +49,12 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert:
|
||||
|
||||
| Kategorie | Nodes | Beschreibung |
|
||||
|-----------|-------|-------------|
|
||||
| **source** (Quelle) | `image`, `text`, `video`, `asset`, `color` | Input-Quellen für den Workflow |
|
||||
| **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text`, `ai-video`, `agent-output` | KI-generierte Inhalte |
|
||||
| **source** (Quelle) | `image`, `text`, `video`, `asset`, `color`, `ai-video` | Input-Quellen für den Workflow |
|
||||
| **ai-output** (KI-Ausgabe) | `prompt`, `video-prompt`, `ai-text` | KI-generierte Inhalte |
|
||||
| **agents** (Agents) | `agent`, `agent-output` | Agent-Orchestrierung und Agent-Outputs |
|
||||
| **transform** (Transformation) | `crop`, `bg-remove`, `upscale` | Bildbearbeitung-Transformationen |
|
||||
| **image-edit** (Bildbearbeitung) | `curves`, `color-adjust`, `light-adjust`, `detail-adjust` | Preset-basierte Adjustments |
|
||||
| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch`, `agent` | Kontrollfluss-Elemente |
|
||||
| **control** (Steuerung & Flow) | `condition`, `loop`, `parallel`, `switch`, `mixer` | Kontrollfluss-Elemente |
|
||||
| **layout** (Canvas & Layout) | `group`, `frame`, `note`, `compare` | Layout-Elemente |
|
||||
|
||||
### Node-Typen im Detail
|
||||
@@ -67,9 +68,9 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert:
|
||||
| `prompt` | 1 | ✅ | ai-output | source: `prompt-out`, target: `image-in` |
|
||||
| `video-prompt` | 2 | ✅ | ai-output | source: `video-prompt-out`, target: `video-prompt-in` |
|
||||
| `ai-text` | 2 | 🔲 | ai-output | source: `text-out`, target: `text-in` |
|
||||
| `ai-video` | 2 | ✅ (systemOutput) | ai-output | source: `video-out`, target: `video-in` |
|
||||
| `agent` | 2 | ✅ | control | target: `agent-in`, source (default) |
|
||||
| `agent-output` | 2 | ✅ (systemOutput) | ai-output | target: `agent-output-in` |
|
||||
| `ai-video` | 2 | ✅ (systemOutput) | source | source: `video-out`, target: `video-in` |
|
||||
| `agent` | 2 | ✅ | agents | target: `agent-in`, source (default) |
|
||||
| `agent-output` | 2 | ✅ (systemOutput) | agents | target: `agent-output-in` |
|
||||
| `crop` | 2 | 🔲 | transform | 🔲 |
|
||||
| `bg-remove` | 2 | 🔲 | transform | 🔲 |
|
||||
| `upscale` | 2 | 🔲 | transform | 🔲 |
|
||||
@@ -81,6 +82,7 @@ Alle verfügbaren Node-Typen sind in `lib/canvas-node-catalog.ts` definiert:
|
||||
| `frame` | 1 | ✅ | layout | source: `frame-out`, target: `frame-in` |
|
||||
| `note` | 1 | ✅ | layout | source (default), target (default) |
|
||||
| `compare` | 1 | ✅ | layout | source: `compare-out`, targets: `left`, `right` |
|
||||
| `mixer` | 1 | ✅ | control | source: `mixer-out`, targets: `base`, `overlay` |
|
||||
|
||||
> `implemented: false` (🔲) bedeutet Phase-2/3 Node, der noch nicht implementiert ist. **Hinweis:** Phase-2/3 Nodes müssen im Schema (`convex/node_type_validator.ts`) vordeklariert werden, damit das System nicht bei jeder Phasenübergang neu migriert werden muss. Die UI filtert Nodes nach Phase.
|
||||
|
||||
@@ -119,10 +121,30 @@ video-prompt: 288 × 220 ai-video: 360 × 280
|
||||
agent: 360 × 320
|
||||
group: 400 × 300 frame: 400 × 300
|
||||
note: 208 × 100 compare: 500 × 380
|
||||
render: 300 × 420 mixer: 360 × 320
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mixer V1 (Merge Node)
|
||||
|
||||
`mixer` ist in V1 ein bewusst enger 2-Layer-Blend-Node.
|
||||
|
||||
- **Handles:** genau zwei Inputs links (`base`, `overlay`) und ein Output rechts (`mixer-out`).
|
||||
- **Erlaubte Inputs:** `image`, `asset`, `ai-image`, `render`.
|
||||
- **Connection-Limits:** maximal 2 eingehende Kanten insgesamt, davon pro Handle genau 1.
|
||||
- **Node-Data (V1):** `blendMode` (`normal|multiply|screen|overlay`), `opacity` (0..100), `offsetX`, `offsetY`.
|
||||
- **Output-Semantik:** pseudo-image (clientseitig aus Graph + Controls aufgeloest), kein persistiertes Asset, kein Storage-Write.
|
||||
- **UI/Interaction:** nur Inline-Formcontrols im Node; keine Drag-Manipulation im Preview, keine Rotation/Skalierung/Masks.
|
||||
|
||||
### Compare-Integration (V1)
|
||||
|
||||
- `compare` versteht `mixer`-Outputs ueber `lib/canvas-mixer-preview.ts`.
|
||||
- Die Vorschau wird als DOM/CSS-Layering im Client gerendert (inkl. Blend/Opacity/Offset).
|
||||
- Scope bleibt eng: keine pauschale pseudo-image-Unterstuetzung fuer alle Consumer in V1.
|
||||
|
||||
---
|
||||
|
||||
## Node-Status-Modell
|
||||
|
||||
```
|
||||
@@ -291,6 +313,8 @@ useCanvasData (use-canvas-data.ts)
|
||||
- **Optimistic IDs:** Temporäre Nodes/Edges erhalten IDs mit `optimistic_` / `optimistic_edge_`-Prefix, werden durch echte Convex-IDs ersetzt, sobald die Mutation abgeschlossen ist.
|
||||
- **Node-Taxonomie:** Alle Node-Typen sind in `lib/canvas-node-catalog.ts` definiert. Phase-2/3 Nodes haben `implemented: false` und `disabledHint`.
|
||||
- **Video-Connection-Policy:** `video-prompt` darf **nur** mit `ai-video` verbunden werden (und umgekehrt). `text → video-prompt` ist erlaubt (Prompt-Quelle). `ai-video → compare` ist erlaubt.
|
||||
- **Mixer-Connection-Policy:** `mixer` akzeptiert nur `image|asset|ai-image|render`; Ziel-Handles sind nur `base` und `overlay`, pro Handle maximal eine eingehende Kante, insgesamt maximal zwei.
|
||||
- **Mixer-Pseudo-Output:** `mixer` liefert in V1 kein persistiertes Bild. Downstream-Nodes muessen den pseudo-image-Resolver nutzen (aktuell gezielt fuer `compare`).
|
||||
- **Agent-Flow:** `agent` akzeptiert nur Content-/Kontext-Quellen (z. B. `render`, `compare`, `text`, `image`) als Input; ausgehende Kanten sind fuer `agent -> agent-output` vorgesehen.
|
||||
- **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren.
|
||||
- **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
|
||||
import {
|
||||
computeEdgeInsertLayout,
|
||||
computeEdgeInsertReflowPlan,
|
||||
getSingleCharacterHotkey,
|
||||
withResolvedCompareData,
|
||||
} from "../canvas-helpers";
|
||||
import {
|
||||
@@ -315,6 +316,24 @@ describe("canvas preview graph helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSingleCharacterHotkey", () => {
|
||||
it("returns a lowercase printable hotkey for single-character keys", () => {
|
||||
expect(getSingleCharacterHotkey({ key: "K", type: "keydown" })).toBe("k");
|
||||
expect(getSingleCharacterHotkey({ key: "v", type: "keydown" })).toBe("v");
|
||||
expect(getSingleCharacterHotkey({ key: "Escape", type: "keydown" })).toBe("");
|
||||
});
|
||||
|
||||
it("returns an empty string and logs when the event has no string key", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
expect(getSingleCharacterHotkey({ type: "keydown" } as { key?: string; type: string })).toBe("");
|
||||
expect(warnSpy).toHaveBeenCalledWith("[Canvas] keyboard event missing string key", {
|
||||
eventType: "keydown",
|
||||
key: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEdgeInsertLayout", () => {
|
||||
it("shifts source and target along a horizontal axis when spacing is too tight", () => {
|
||||
const source = createNode({
|
||||
|
||||
@@ -166,4 +166,99 @@ describe("CompareNode render preview inputs", () => {
|
||||
preferPreview: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers mixer composite preview over persisted compare finalUrl when mixer is connected", () => {
|
||||
storeState.nodes = [
|
||||
{
|
||||
id: "base-image",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-image",
|
||||
type: "asset",
|
||||
data: { url: "https://cdn.example.com/overlay.png" },
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {
|
||||
blendMode: "multiply",
|
||||
opacity: 62,
|
||||
offsetX: 12,
|
||||
offsetY: -4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "right-image",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/right.png" },
|
||||
},
|
||||
];
|
||||
storeState.edges = [
|
||||
{
|
||||
id: "edge-base-mixer",
|
||||
source: "base-image",
|
||||
target: "mixer-1",
|
||||
targetHandle: "base",
|
||||
},
|
||||
{
|
||||
id: "edge-overlay-mixer",
|
||||
source: "overlay-image",
|
||||
target: "mixer-1",
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
{
|
||||
id: "edge-mixer-compare",
|
||||
source: "mixer-1",
|
||||
target: "compare-1",
|
||||
targetHandle: "left",
|
||||
},
|
||||
{
|
||||
id: "edge-image-compare",
|
||||
source: "right-image",
|
||||
target: "compare-1",
|
||||
targetHandle: "right",
|
||||
},
|
||||
];
|
||||
|
||||
renderCompareNode({
|
||||
id: "compare-1",
|
||||
data: {
|
||||
leftUrl: "https://cdn.example.com/base.png",
|
||||
rightUrl: "https://cdn.example.com/right.png",
|
||||
},
|
||||
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(compareSurfaceSpy).toHaveBeenCalledTimes(2);
|
||||
const mixerCall = compareSurfaceSpy.mock.calls.find(
|
||||
([props]) =>
|
||||
Boolean((props as { mixerPreviewState?: { status?: string } }).mixerPreviewState),
|
||||
);
|
||||
expect(mixerCall?.[0]).toMatchObject({
|
||||
finalUrl: undefined,
|
||||
mixerPreviewState: {
|
||||
status: "ready",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: "https://cdn.example.com/overlay.png",
|
||||
blendMode: "multiply",
|
||||
opacity: 62,
|
||||
offsetX: 12,
|
||||
offsetY: -4,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
229
components/canvas/__tests__/mixer-node.test.tsx
Normal file
229
components/canvas/__tests__/mixer-node.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queueNodeDataUpdate: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
Handle: ({ id, type }: { id?: string; type: string }) => (
|
||||
<div data-testid={`handle-${id ?? "default"}`} data-handle-id={id} data-handle-type={type} />
|
||||
),
|
||||
Position: { Left: "left", Right: "right" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/canvas-sync-context", () => ({
|
||||
useCanvasSync: () => ({
|
||||
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
|
||||
queueNodeResize: vi.fn(async () => undefined),
|
||||
status: { pendingCount: 0, isSyncing: false, isOffline: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
import MixerNode from "@/components/canvas/nodes/mixer-node";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type TestNode = {
|
||||
id: string;
|
||||
type: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
type TestEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
targetHandle?: string;
|
||||
};
|
||||
|
||||
function buildMixerNodeProps(overrides?: Partial<React.ComponentProps<typeof MixerNode>>) {
|
||||
return {
|
||||
id: "mixer-1",
|
||||
data: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
selected: false,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
type: "mixer",
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
width: 360,
|
||||
height: 300,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
...overrides,
|
||||
} as React.ComponentProps<typeof MixerNode>;
|
||||
}
|
||||
|
||||
describe("MixerNode", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.queueNodeDataUpdate.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
async function renderNode(args?: {
|
||||
nodes?: TestNode[];
|
||||
edges?: TestEdge[];
|
||||
props?: Partial<React.ComponentProps<typeof MixerNode>>;
|
||||
}) {
|
||||
const nodes = args?.nodes ?? [{ id: "mixer-1", type: "mixer", data: {} }];
|
||||
const edges = args?.edges ?? [];
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<CanvasGraphProvider nodes={nodes} edges={edges}>
|
||||
<MixerNode {...buildMixerNodeProps(args?.props)} />
|
||||
</CanvasGraphProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("renders empty state copy when no inputs are connected", async () => {
|
||||
await renderNode();
|
||||
|
||||
expect(container?.textContent).toContain("Connect base and overlay images");
|
||||
});
|
||||
|
||||
it("renders partial state copy when only one input is connected", async () => {
|
||||
await renderNode({
|
||||
nodes: [
|
||||
{ id: "image-1", type: "image", data: { url: "https://cdn.example.com/base.png" } },
|
||||
{ id: "mixer-1", type: "mixer", data: {} },
|
||||
],
|
||||
edges: [{ id: "edge-base", source: "image-1", target: "mixer-1", targetHandle: "base" }],
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("Waiting for second input");
|
||||
});
|
||||
|
||||
it("renders ready state with stacked base and overlay previews", async () => {
|
||||
await renderNode({
|
||||
nodes: [
|
||||
{ id: "image-base", type: "image", data: { url: "https://cdn.example.com/base.png" } },
|
||||
{ id: "image-overlay", type: "asset", data: { url: "https://cdn.example.com/overlay.png" } },
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: { blendMode: "multiply", opacity: 60, offsetX: 14, offsetY: -8 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: "edge-base", source: "image-base", target: "mixer-1", targetHandle: "base" },
|
||||
{
|
||||
id: "edge-overlay",
|
||||
source: "image-overlay",
|
||||
target: "mixer-1",
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const baseImage = container?.querySelector('img[alt="Mixer base"]');
|
||||
const overlayImage = container?.querySelector('img[alt="Mixer overlay"]');
|
||||
|
||||
expect(baseImage).toBeTruthy();
|
||||
expect(overlayImage).toBeTruthy();
|
||||
});
|
||||
|
||||
it("queues node data updates for blend mode, opacity, and overlay offsets", async () => {
|
||||
await renderNode();
|
||||
|
||||
const blendMode = container?.querySelector('select[name="blendMode"]');
|
||||
const opacity = container?.querySelector('input[name="opacity"]');
|
||||
const offsetX = container?.querySelector('input[name="offsetX"]');
|
||||
const offsetY = container?.querySelector('input[name="offsetY"]');
|
||||
|
||||
if (!(blendMode instanceof HTMLSelectElement)) {
|
||||
throw new Error("blendMode select not found");
|
||||
}
|
||||
if (!(opacity instanceof HTMLInputElement)) {
|
||||
throw new Error("opacity input not found");
|
||||
}
|
||||
if (!(offsetX instanceof HTMLInputElement)) {
|
||||
throw new Error("offsetX input not found");
|
||||
}
|
||||
if (!(offsetY instanceof HTMLInputElement)) {
|
||||
throw new Error("offsetY input not found");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
blendMode.value = "screen";
|
||||
blendMode.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({ blendMode: "screen" }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
opacity.value = "45";
|
||||
opacity.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
opacity.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({ opacity: 45 }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
offsetX.value = "12";
|
||||
offsetX.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
offsetX.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({ offsetX: 12 }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
offsetY.value = "-6";
|
||||
offsetY.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
offsetY.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({
|
||||
nodeId: "mixer-1",
|
||||
data: expect.objectContaining({ offsetY: -6 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders expected mixer handles", async () => {
|
||||
await renderNode();
|
||||
|
||||
expect(container?.querySelector('[data-handle-id="base"][data-handle-type="target"]')).toBeTruthy();
|
||||
expect(container?.querySelector('[data-handle-id="overlay"][data-handle-type="target"]')).toBeTruthy();
|
||||
expect(container?.querySelector('[data-handle-id="mixer-out"][data-handle-type="source"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -22,16 +22,12 @@ vi.mock("@/components/canvas/canvas-helpers", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/canvas/canvas-reconnect", () => ({
|
||||
useCanvasReconnectHandlers: () => ({
|
||||
onReconnectStart: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onReconnectEnd: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
|
||||
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers";
|
||||
import { nodeTypes } from "@/components/canvas/node-types";
|
||||
import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
|
||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
|
||||
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
|
||||
|
||||
@@ -45,7 +41,10 @@ type HookHarnessProps = {
|
||||
helperResult: DroppedConnectionTarget | null;
|
||||
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
|
||||
runSplitEdgeAtExistingNodeMutation?: ReturnType<typeof vi.fn>;
|
||||
runRemoveEdgeMutation?: ReturnType<typeof vi.fn>;
|
||||
runSwapMixerInputsMutation?: ReturnType<typeof vi.fn>;
|
||||
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
|
||||
setEdgesMock?: ReturnType<typeof vi.fn>;
|
||||
nodes?: RFNode[];
|
||||
edges?: RFEdge[];
|
||||
};
|
||||
@@ -54,7 +53,10 @@ function HookHarness({
|
||||
helperResult,
|
||||
runCreateEdgeMutation = vi.fn(async () => undefined),
|
||||
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
|
||||
runRemoveEdgeMutation = vi.fn(async () => undefined),
|
||||
runSwapMixerInputsMutation = vi.fn(async () => undefined),
|
||||
showConnectionRejectedToast = vi.fn(),
|
||||
setEdgesMock,
|
||||
nodes: providedNodes,
|
||||
edges: providedEdges,
|
||||
}: HookHarnessProps) {
|
||||
@@ -71,7 +73,7 @@ function HookHarness({
|
||||
const isReconnectDragActiveRef = useRef(false);
|
||||
const pendingConnectionCreatesRef = useRef(new Set<string>());
|
||||
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
|
||||
const setEdges = vi.fn();
|
||||
const setEdges = setEdgesMock ?? vi.fn();
|
||||
const setEdgeSyncNonce = vi.fn();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,7 +104,8 @@ function HookHarness({
|
||||
syncPendingMoveForClientRequest: vi.fn(async () => undefined),
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
||||
runRemoveEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"),
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"),
|
||||
showConnectionRejectedToast,
|
||||
@@ -132,6 +135,47 @@ describe("useCanvasConnections", () => {
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("exposes mixer metadata required for placement and connection defaults", () => {
|
||||
const mixerCatalogEntry = NODE_CATALOG.find((entry) => entry.type === "mixer");
|
||||
const mixerTemplate = CANVAS_NODE_TEMPLATES.find(
|
||||
(template) => (template.type as string) === "mixer",
|
||||
);
|
||||
|
||||
expect(nodeTypes).toHaveProperty("mixer");
|
||||
expect(mixerCatalogEntry).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "mixer",
|
||||
category: "control",
|
||||
implemented: true,
|
||||
}),
|
||||
);
|
||||
expect(mixerTemplate).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "mixer",
|
||||
defaultData: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(NODE_HANDLE_MAP.mixer).toEqual({
|
||||
source: "mixer-out",
|
||||
target: "base",
|
||||
});
|
||||
expect(NODE_DEFAULTS.mixer).toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates an edge when a body drop lands on another node", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
@@ -490,6 +534,320 @@ describe("useCanvasConnections", () => {
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("allows image-like sources to connect to mixer", 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={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
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: {} },
|
||||
]}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects disallowed source types to mixer", 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={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "base",
|
||||
}}
|
||||
nodes={[
|
||||
{ id: "node-source", type: "video", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||
]}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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: "video" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 400, y: 260 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-source-invalid");
|
||||
});
|
||||
|
||||
it("rejects a second connection to the same mixer handle", 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={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "base",
|
||||
}}
|
||||
nodes={[
|
||||
{ id: "node-source", type: "asset", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-existing-base",
|
||||
source: "node-image",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
},
|
||||
]}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnect({
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
sourceHandle: null,
|
||||
targetHandle: "base",
|
||||
});
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-handle-incoming-limit");
|
||||
});
|
||||
|
||||
it("allows one incoming edge per mixer handle", 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={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "overlay",
|
||||
}}
|
||||
nodes={[
|
||||
{ id: "node-source", type: "asset", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-existing-base",
|
||||
source: "node-image",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
},
|
||||
]}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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: "asset" },
|
||||
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: "overlay",
|
||||
});
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a third incoming edge to mixer", 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={{
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "base",
|
||||
}}
|
||||
nodes={[
|
||||
{ id: "node-source", type: "render", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
|
||||
{ id: "node-image", type: "image", position: { x: -200, y: 40 }, data: {} },
|
||||
{ id: "node-asset", type: "asset", position: { x: -180, y: 180 }, data: {} },
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-existing-base",
|
||||
source: "node-image",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
},
|
||||
{
|
||||
id: "edge-existing-overlay",
|
||||
source: "node-asset",
|
||||
target: "node-target",
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
]}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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: "render" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 400, y: 260 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("mixer-incoming-limit");
|
||||
});
|
||||
|
||||
it("ignores onConnectEnd when no connect drag is active", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
@@ -535,4 +893,364 @@ describe("useCanvasConnections", () => {
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("passes edgeIdToIgnore during reconnect replacement without client-side old-edge delete", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const runRemoveEdgeMutation = 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}
|
||||
runRemoveEdgeMutation={runRemoveEdgeMutation}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-1",
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-source",
|
||||
targetNodeId: "node-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "overlay",
|
||||
edgeIdToIgnore: "edge-1",
|
||||
});
|
||||
expect(runRemoveEdgeMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not remove old edge when reconnect create fails", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => {
|
||||
throw new Error("incoming limit reached");
|
||||
});
|
||||
const runRemoveEdgeMutation = 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}
|
||||
runRemoveEdgeMutation={runRemoveEdgeMutation}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-1",
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
targetHandle: "base",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledTimes(1);
|
||||
expect(runRemoveEdgeMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swaps mixer inputs on reconnect when dropping onto occupied opposite handle (base->overlay)", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const runRemoveEdgeMutation = vi.fn(async () => undefined);
|
||||
const runSwapMixerInputsMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
const setEdgesMock = vi.fn();
|
||||
|
||||
const initialEdges: RFEdge[] = [
|
||||
{
|
||||
id: "edge-base",
|
||||
source: "node-source-base",
|
||||
target: "node-mixer",
|
||||
targetHandle: "base",
|
||||
},
|
||||
{
|
||||
id: "edge-overlay",
|
||||
source: "node-source-overlay",
|
||||
target: "node-mixer",
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={null}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
runRemoveEdgeMutation={runRemoveEdgeMutation}
|
||||
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
setEdgesMock={setEdgesMock}
|
||||
nodes={[
|
||||
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-source-overlay", type: "asset", position: { x: 0, y: 120 }, data: {} },
|
||||
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
|
||||
]}
|
||||
edges={initialEdges}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const oldEdge = initialEdges[0] as RFEdge;
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onReconnectStart();
|
||||
latestHandlersRef.current?.onReconnect(oldEdge, {
|
||||
source: "node-source-base",
|
||||
target: "node-mixer",
|
||||
sourceHandle: null,
|
||||
targetHandle: "overlay",
|
||||
});
|
||||
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(runRemoveEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(runSwapMixerInputsMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
edgeId: "edge-base",
|
||||
otherEdgeId: "edge-overlay",
|
||||
});
|
||||
|
||||
expect(setEdgesMock).toHaveBeenCalledTimes(1);
|
||||
const applyEdges = setEdgesMock.mock.calls[0]?.[0] as ((edges: RFEdge[]) => RFEdge[]);
|
||||
const swappedEdges = applyEdges(initialEdges);
|
||||
const baseEdge = swappedEdges.find((edge) => edge.id === "edge-base");
|
||||
const overlayEdge = swappedEdges.find((edge) => edge.id === "edge-overlay");
|
||||
expect(baseEdge?.targetHandle).toBe("overlay");
|
||||
expect(overlayEdge?.targetHandle).toBe("base");
|
||||
});
|
||||
|
||||
it("swaps mixer inputs on reconnect when dropping onto occupied opposite handle (overlay->base)", async () => {
|
||||
const runSwapMixerInputsMutation = 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}
|
||||
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
nodes={[
|
||||
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-source-overlay", type: "asset", position: { x: 0, y: 120 }, data: {} },
|
||||
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-base",
|
||||
source: "node-source-base",
|
||||
target: "node-mixer",
|
||||
targetHandle: "base",
|
||||
},
|
||||
{
|
||||
id: "edge-overlay",
|
||||
source: "node-source-overlay",
|
||||
target: "node-mixer",
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const oldEdge = {
|
||||
id: "edge-overlay",
|
||||
source: "node-source-overlay",
|
||||
target: "node-mixer",
|
||||
targetHandle: "overlay",
|
||||
} as RFEdge;
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onReconnectStart();
|
||||
latestHandlersRef.current?.onReconnect(oldEdge, {
|
||||
source: "node-source-overlay",
|
||||
target: "node-mixer",
|
||||
sourceHandle: null,
|
||||
targetHandle: "base",
|
||||
});
|
||||
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(runSwapMixerInputsMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
edgeId: "edge-overlay",
|
||||
otherEdgeId: "edge-base",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not swap mixer reconnect when target mixer is not fully populated", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const runSwapMixerInputsMutation = 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}
|
||||
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
nodes={[
|
||||
{ id: "node-source-base", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-mixer", type: "mixer", position: { x: 320, y: 120 }, data: {} },
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-base",
|
||||
source: "node-source-base",
|
||||
target: "node-mixer",
|
||||
targetHandle: "base",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const oldEdge = {
|
||||
id: "edge-base",
|
||||
source: "node-source-base",
|
||||
target: "node-mixer",
|
||||
targetHandle: "base",
|
||||
} as RFEdge;
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onReconnectStart();
|
||||
latestHandlersRef.current?.onReconnect(oldEdge, {
|
||||
source: "node-source-base",
|
||||
target: "node-mixer",
|
||||
sourceHandle: null,
|
||||
targetHandle: "overlay",
|
||||
});
|
||||
latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
sourceNodeId: "node-source-base",
|
||||
targetNodeId: "node-mixer",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: "overlay",
|
||||
edgeIdToIgnore: "edge-base",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not perform mixer swap for non-mixer reconnect validation failures", async () => {
|
||||
const runSwapMixerInputsMutation = 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}
|
||||
runSwapMixerInputsMutation={runSwapMixerInputsMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
nodes={[
|
||||
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-render", type: "render", position: { x: 300, y: 0 }, data: {} },
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-1",
|
||||
source: "node-image",
|
||||
target: "node-render",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const oldEdge = {
|
||||
id: "edge-1",
|
||||
source: "node-image",
|
||||
target: "node-render",
|
||||
} as RFEdge;
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onReconnectStart();
|
||||
latestHandlersRef.current?.onReconnect(oldEdge, {
|
||||
source: "node-image",
|
||||
target: "node-image",
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||
import { getSingleCharacterHotkey } from "@/components/canvas/canvas-helpers";
|
||||
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
|
||||
import {
|
||||
Command,
|
||||
@@ -98,7 +99,7 @@ export function CanvasCommandPalette() {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.metaKey && !e.ctrlKey) return;
|
||||
if (e.key.toLowerCase() !== "k") return;
|
||||
if (getSingleCharacterHotkey(e) !== "k") return;
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
validateCanvasConnectionPolicy,
|
||||
type CanvasConnectionValidationReason,
|
||||
} from "@/lib/canvas-connection-policy";
|
||||
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
|
||||
import { isOptimisticEdgeId } from "./canvas-helpers";
|
||||
|
||||
@@ -27,6 +28,7 @@ export function validateCanvasConnection(
|
||||
sourceType: sourceNode.type ?? "",
|
||||
targetType: targetNode.type ?? "",
|
||||
targetNodeId: connection.target,
|
||||
targetHandle: connection.targetHandle,
|
||||
edges,
|
||||
edgeToReplaceId,
|
||||
includeOptimisticEdges: options?.includeOptimisticEdges,
|
||||
@@ -37,22 +39,25 @@ export function validateCanvasConnectionByType(args: {
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
targetNodeId: string;
|
||||
targetHandle?: string | null;
|
||||
edges: RFEdge[];
|
||||
edgeToReplaceId?: string;
|
||||
includeOptimisticEdges?: boolean;
|
||||
}): CanvasConnectionValidationReason | null {
|
||||
const targetIncomingCount = args.edges.filter(
|
||||
const targetIncomingEdges = args.edges.filter(
|
||||
(edge) =>
|
||||
edge.className !== "temp" &&
|
||||
(args.includeOptimisticEdges || !isOptimisticEdgeId(edge.id)) &&
|
||||
edge.target === args.targetNodeId &&
|
||||
edge.id !== args.edgeToReplaceId,
|
||||
).length;
|
||||
);
|
||||
|
||||
return validateCanvasConnectionPolicy({
|
||||
sourceType: args.sourceType,
|
||||
targetType: args.targetType,
|
||||
targetIncomingCount,
|
||||
targetIncomingCount: targetIncomingEdges.length,
|
||||
targetHandle: args.targetHandle,
|
||||
targetIncomingHandles: targetIncomingEdges.map((edge) => edge.targetHandle),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,17 +74,21 @@ export function validateCanvasEdgeSplit(args: {
|
||||
return "unknown-node";
|
||||
}
|
||||
|
||||
const middleNodeHandles = NODE_HANDLE_MAP[args.middleNode.type ?? ""];
|
||||
|
||||
return (
|
||||
validateCanvasConnectionByType({
|
||||
sourceType: sourceNode.type ?? "",
|
||||
targetType: args.middleNode.type ?? "",
|
||||
targetNodeId: args.middleNode.id,
|
||||
targetHandle: middleNodeHandles?.target,
|
||||
edges: args.edges,
|
||||
}) ??
|
||||
validateCanvasConnectionByType({
|
||||
sourceType: args.middleNode.type ?? "",
|
||||
targetType: targetNode.type ?? "",
|
||||
targetNodeId: targetNode.id,
|
||||
targetHandle: args.splitEdge.targetHandle,
|
||||
edges: args.edges,
|
||||
edgeToReplaceId: args.splitEdge.id,
|
||||
})
|
||||
|
||||
@@ -759,6 +759,18 @@ export function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
||||
return target.closest("input, textarea, select, [contenteditable=true]") !== null;
|
||||
}
|
||||
|
||||
export function getSingleCharacterHotkey(event: { key?: string; type: string }): string {
|
||||
if (typeof event.key !== "string") {
|
||||
console.warn("[Canvas] keyboard event missing string key", {
|
||||
eventType: event.type,
|
||||
key: event.key,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
return event.key.length === 1 ? event.key.toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function isEdgeCuttable(edge: RFEdge): boolean {
|
||||
if (edge.className === "temp") return false;
|
||||
if (isOptimisticEdgeId(edge.id)) return false;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Image,
|
||||
Package,
|
||||
Palette,
|
||||
Layers,
|
||||
Sparkles,
|
||||
StickyNote,
|
||||
Sun,
|
||||
@@ -43,6 +44,7 @@ const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
|
||||
"light-adjust": Sun,
|
||||
"detail-adjust": Focus,
|
||||
render: ImageDown,
|
||||
mixer: Layers,
|
||||
};
|
||||
|
||||
const NODE_SEARCH_KEYWORDS: Partial<
|
||||
|
||||
@@ -16,12 +16,28 @@ type UseCanvasReconnectHandlersParams = {
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
}) => Promise<unknown>;
|
||||
runSwapMixerInputsMutation?: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
}) => Promise<unknown>;
|
||||
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||
validateConnection?: (
|
||||
oldEdge: RFEdge,
|
||||
newConnection: Connection,
|
||||
) => string | null;
|
||||
resolveMixerSwapReconnect?: (
|
||||
oldEdge: RFEdge,
|
||||
newConnection: Connection,
|
||||
validationError: string,
|
||||
) => {
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
nextEdgeHandle: "base" | "overlay";
|
||||
nextOtherEdgeHandle: "base" | "overlay";
|
||||
} | null;
|
||||
onInvalidConnection?: (message: string) => void;
|
||||
};
|
||||
|
||||
@@ -31,18 +47,29 @@ export function useCanvasReconnectHandlers({
|
||||
isReconnectDragActiveRef,
|
||||
setEdges,
|
||||
runCreateEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
runRemoveEdgeMutation,
|
||||
validateConnection,
|
||||
resolveMixerSwapReconnect,
|
||||
onInvalidConnection,
|
||||
}: UseCanvasReconnectHandlersParams): {
|
||||
onReconnectStart: () => void;
|
||||
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void;
|
||||
} {
|
||||
const pendingReconnectRef = useRef<{
|
||||
oldEdge: RFEdge;
|
||||
newConnection: Connection;
|
||||
} | null>(null);
|
||||
const pendingReconnectRef = useRef<
|
||||
| {
|
||||
kind: "replace";
|
||||
oldEdge: RFEdge;
|
||||
newConnection: Connection;
|
||||
}
|
||||
| {
|
||||
kind: "swap";
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const onReconnectStart = useCallback(() => {
|
||||
edgeReconnectSuccessful.current = false;
|
||||
@@ -54,6 +81,38 @@ export function useCanvasReconnectHandlers({
|
||||
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||
const validationError = validateConnection?.(oldEdge, newConnection) ?? null;
|
||||
if (validationError) {
|
||||
const swapReconnect = resolveMixerSwapReconnect?.(
|
||||
oldEdge,
|
||||
newConnection,
|
||||
validationError,
|
||||
);
|
||||
if (swapReconnect) {
|
||||
edgeReconnectSuccessful.current = true;
|
||||
pendingReconnectRef.current = {
|
||||
kind: "swap",
|
||||
edgeId: swapReconnect.edgeId,
|
||||
otherEdgeId: swapReconnect.otherEdgeId,
|
||||
};
|
||||
setEdges((currentEdges) =>
|
||||
currentEdges.map((candidate) => {
|
||||
if (candidate.id === swapReconnect.edgeId) {
|
||||
return {
|
||||
...candidate,
|
||||
targetHandle: swapReconnect.nextEdgeHandle,
|
||||
};
|
||||
}
|
||||
if (candidate.id === swapReconnect.otherEdgeId) {
|
||||
return {
|
||||
...candidate,
|
||||
targetHandle: swapReconnect.nextOtherEdgeHandle,
|
||||
};
|
||||
}
|
||||
return candidate;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
edgeReconnectSuccessful.current = true;
|
||||
pendingReconnectRef.current = null;
|
||||
onInvalidConnection?.(validationError);
|
||||
@@ -61,10 +120,20 @@ export function useCanvasReconnectHandlers({
|
||||
}
|
||||
|
||||
edgeReconnectSuccessful.current = true;
|
||||
pendingReconnectRef.current = { oldEdge, newConnection };
|
||||
pendingReconnectRef.current = {
|
||||
kind: "replace",
|
||||
oldEdge,
|
||||
newConnection,
|
||||
};
|
||||
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
|
||||
},
|
||||
[edgeReconnectSuccessful, onInvalidConnection, setEdges, validateConnection],
|
||||
[
|
||||
edgeReconnectSuccessful,
|
||||
onInvalidConnection,
|
||||
resolveMixerSwapReconnect,
|
||||
setEdges,
|
||||
validateConnection,
|
||||
],
|
||||
);
|
||||
|
||||
const onReconnectEnd = useCallback(
|
||||
@@ -95,32 +164,35 @@ export function useCanvasReconnectHandlers({
|
||||
|
||||
const pendingReconnect = pendingReconnectRef.current;
|
||||
pendingReconnectRef.current = null;
|
||||
if (
|
||||
pendingReconnect &&
|
||||
pendingReconnect.newConnection.source &&
|
||||
pendingReconnect.newConnection.target
|
||||
) {
|
||||
if (pendingReconnect?.kind === "replace" && pendingReconnect.newConnection.source && pendingReconnect.newConnection.target) {
|
||||
void runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: pendingReconnect.newConnection.source as Id<"nodes">,
|
||||
targetNodeId: pendingReconnect.newConnection.target as Id<"nodes">,
|
||||
sourceHandle: pendingReconnect.newConnection.sourceHandle ?? undefined,
|
||||
targetHandle: pendingReconnect.newConnection.targetHandle ?? undefined,
|
||||
}).catch((error) => {
|
||||
console.error("[Canvas edge reconnect failed] create edge", {
|
||||
oldEdgeId: pendingReconnect.oldEdge.id,
|
||||
source: pendingReconnect.newConnection.source,
|
||||
target: pendingReconnect.newConnection.target,
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
|
||||
if (pendingReconnect.oldEdge.className !== "temp") {
|
||||
void runRemoveEdgeMutation({
|
||||
edgeId: pendingReconnect.oldEdge.id as Id<"edges">,
|
||||
}).catch((error) => {
|
||||
console.error("[Canvas edge reconnect failed] remove old edge", {
|
||||
edgeIdToIgnore: pendingReconnect.oldEdge.id as Id<"edges">,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Canvas edge reconnect failed] create edge", {
|
||||
oldEdgeId: pendingReconnect.oldEdge.id,
|
||||
source: pendingReconnect.newConnection.source,
|
||||
target: pendingReconnect.newConnection.target,
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingReconnect?.kind === "swap") {
|
||||
if (runSwapMixerInputsMutation) {
|
||||
void runSwapMixerInputsMutation({
|
||||
canvasId,
|
||||
edgeId: pendingReconnect.edgeId,
|
||||
otherEdgeId: pendingReconnect.otherEdgeId,
|
||||
}).catch((error) => {
|
||||
console.error("[Canvas edge reconnect failed] swap mixer inputs", {
|
||||
edgeId: pendingReconnect.edgeId,
|
||||
otherEdgeId: pendingReconnect.otherEdgeId,
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
@@ -138,6 +210,7 @@ export function useCanvasReconnectHandlers({
|
||||
isReconnectDragActiveRef,
|
||||
runCreateEdgeMutation,
|
||||
runRemoveEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
setEdges,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasNavTool } from "@/components/canvas/canvas-toolbar";
|
||||
import {
|
||||
collectCuttableEdgesAlongScreenSegment,
|
||||
getSingleCharacterHotkey,
|
||||
getIntersectedEdgeId,
|
||||
isEdgeCuttable,
|
||||
isEditableKeyboardTarget,
|
||||
@@ -50,8 +51,7 @@ export function useCanvasScissors({
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const isScissorHotkey =
|
||||
event.key.length === 1 && event.key.toLowerCase() === "k";
|
||||
const isScissorHotkey = getSingleCharacterHotkey(event) === "k";
|
||||
if (!isScissorHotkey) return;
|
||||
if (isEditableKeyboardTarget(event.target)) return;
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
||||
import {
|
||||
CANVAS_MIN_ZOOM,
|
||||
DEFAULT_EDGE_OPTIONS,
|
||||
getSingleCharacterHotkey,
|
||||
getMiniMapNodeColor,
|
||||
getMiniMapNodeStrokeColor,
|
||||
getPendingRemovedEdgeIdsFromLocalOps,
|
||||
@@ -100,6 +101,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
|
||||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
|
||||
const runSwapMixerInputsMutation = useMutation(api.edges.swapMixerInputs);
|
||||
const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set<string>());
|
||||
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
|
||||
string | null
|
||||
@@ -237,7 +239,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (isEditableKeyboardTarget(e.target)) return;
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : "";
|
||||
const key = getSingleCharacterHotkey(e);
|
||||
if (key === "v") {
|
||||
e.preventDefault();
|
||||
handleNavToolChange("select");
|
||||
@@ -342,6 +344,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
runCreateEdgeMutation,
|
||||
runSplitEdgeAtExistingNodeMutation,
|
||||
runRemoveEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||
showConnectionRejectedToast,
|
||||
|
||||
@@ -18,6 +18,7 @@ import RenderNode from "./nodes/render-node";
|
||||
import CropNode from "./nodes/crop-node";
|
||||
import AgentNode from "./nodes/agent-node";
|
||||
import AgentOutputNode from "./nodes/agent-output-node";
|
||||
import MixerNode from "./nodes/mixer-node";
|
||||
|
||||
/**
|
||||
* Node-Type-Map für React Flow.
|
||||
@@ -46,5 +47,6 @@ export const nodeTypes = {
|
||||
crop: CropNode,
|
||||
render: RenderNode,
|
||||
agent: AgentNode,
|
||||
mixer: MixerNode,
|
||||
"agent-output": AgentOutputNode,
|
||||
} as const;
|
||||
|
||||
@@ -20,6 +20,7 @@ type AgentOutputNodeData = {
|
||||
content?: string;
|
||||
}>;
|
||||
metadata?: Record<string, string | string[] | unknown>;
|
||||
metadataLabels?: Record<string, string | unknown>;
|
||||
qualityChecks?: string[];
|
||||
outputType?: string;
|
||||
body?: string;
|
||||
@@ -102,6 +103,18 @@ function normalizeMetadata(raw: AgentOutputNodeData["metadata"]) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveMetadataLabel(
|
||||
key: string,
|
||||
rawLabels: AgentOutputNodeData["metadataLabels"],
|
||||
): string {
|
||||
if (!rawLabels || typeof rawLabels !== "object" || Array.isArray(rawLabels)) {
|
||||
return key;
|
||||
}
|
||||
|
||||
const candidate = rawLabels[key];
|
||||
return typeof candidate === "string" && candidate.trim() !== "" ? candidate.trim() : key;
|
||||
}
|
||||
|
||||
function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
@@ -113,6 +126,66 @@ function normalizeQualityChecks(raw: AgentOutputNodeData["qualityChecks"]): stri
|
||||
.filter((value) => value !== "");
|
||||
}
|
||||
|
||||
function normalizeSectionToken(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function partitionSections(
|
||||
sections: Array<{ id: string; label: string; content: string }>,
|
||||
artifactType: string,
|
||||
) {
|
||||
const artifactToken = normalizeSectionToken(artifactType);
|
||||
const priorityTokens = artifactToken === "socialcaptionpack" ? ["caption", "hashtags", "cta"] : [];
|
||||
const isSecondaryNote = (label: string) => {
|
||||
const token = normalizeSectionToken(label);
|
||||
return token.includes("formatnote") || token.includes("assumption");
|
||||
};
|
||||
|
||||
const primaryWithIndex: Array<{ section: (typeof sections)[number]; index: number }> = [];
|
||||
const secondary: Array<{ id: string; label: string; content: string }> = [];
|
||||
|
||||
sections.forEach((section, index) => {
|
||||
if (isSecondaryNote(section.label)) {
|
||||
secondary.push(section);
|
||||
return;
|
||||
}
|
||||
primaryWithIndex.push({ section, index });
|
||||
});
|
||||
|
||||
if (priorityTokens.length === 0) {
|
||||
return {
|
||||
primary: primaryWithIndex.map((entry) => entry.section),
|
||||
secondary,
|
||||
};
|
||||
}
|
||||
|
||||
const priorityIndexByToken = new Map(priorityTokens.map((token, index) => [token, index]));
|
||||
const primary = [...primaryWithIndex]
|
||||
.sort((left, right) => {
|
||||
const leftToken = normalizeSectionToken(left.section.label);
|
||||
const rightToken = normalizeSectionToken(right.section.label);
|
||||
const leftPriority = priorityIndexByToken.get(leftToken);
|
||||
const rightPriority = priorityIndexByToken.get(rightToken);
|
||||
|
||||
if (leftPriority !== undefined && rightPriority !== undefined) {
|
||||
return leftPriority - rightPriority;
|
||||
}
|
||||
if (leftPriority !== undefined) {
|
||||
return -1;
|
||||
}
|
||||
if (rightPriority !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map((entry) => entry.section);
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutputNodeType>) {
|
||||
const t = useTranslations("agentOutputNode");
|
||||
const nodeData = data as AgentOutputNodeData;
|
||||
@@ -140,14 +213,23 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
const body = nodeData.body ?? "";
|
||||
const artifactType = nodeData.artifactType ?? nodeData.outputType ?? "";
|
||||
const sections = normalizeSections(nodeData.sections);
|
||||
const { primary: primarySections, secondary: secondarySections } = partitionSections(
|
||||
sections,
|
||||
artifactType,
|
||||
);
|
||||
const metadataEntries = normalizeMetadata(nodeData.metadata);
|
||||
const metadataLabels = nodeData.metadataLabels;
|
||||
const qualityChecks = normalizeQualityChecks(nodeData.qualityChecks);
|
||||
const previewText =
|
||||
typeof nodeData.previewText === "string" && nodeData.previewText.trim() !== ""
|
||||
? nodeData.previewText.trim()
|
||||
: sections[0]?.content ?? "";
|
||||
: primarySections[0]?.content ?? sections[0]?.content ?? "";
|
||||
const hasStructuredOutput =
|
||||
sections.length > 0 || metadataEntries.length > 0 || qualityChecks.length > 0 || previewText !== "";
|
||||
const hasMetaValues =
|
||||
(typeof nodeData.channel === "string" && nodeData.channel.trim() !== "") || artifactType.trim() !== "";
|
||||
const hasDetailsContent =
|
||||
secondarySections.length > 0 || metadataEntries.length > 0 || qualityChecks.length > 0 || hasMetaValues;
|
||||
const formattedJsonBody = isSkeleton ? null : tryFormatJsonBody(body);
|
||||
|
||||
return (
|
||||
@@ -186,24 +268,6 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section
|
||||
data-testid="agent-output-meta-strip"
|
||||
className="grid grid-cols-2 gap-2 rounded-md border border-border/70 bg-muted/30 px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("channelLabel")}</p>
|
||||
<p className="truncate text-xs font-medium text-foreground/90" title={nodeData.channel}>
|
||||
{nodeData.channel ?? t("emptyValue")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("artifactTypeLabel")}</p>
|
||||
<p className="truncate text-xs font-medium text-foreground/90" title={artifactType}>
|
||||
{artifactType || t("emptyValue")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isSkeleton ? (
|
||||
<section className="space-y-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
@@ -218,11 +282,20 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
</section>
|
||||
) : hasStructuredOutput ? (
|
||||
<>
|
||||
{sections.length > 0 ? (
|
||||
{previewText !== "" ? (
|
||||
<section data-testid="agent-output-preview" className="space-y-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("previewLabel")}</p>
|
||||
<div className="max-h-40 overflow-auto rounded-md border border-border/70 bg-background/70 p-3 text-[13px] leading-relaxed text-foreground/90">
|
||||
<p className="whitespace-pre-wrap break-words">{previewText}</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{primarySections.length > 0 ? (
|
||||
<section data-testid="agent-output-sections" className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("sectionsLabel")}</p>
|
||||
<div className="space-y-1.5">
|
||||
{sections.map((section) => (
|
||||
{primarySections.map((section) => (
|
||||
<div key={section.id} className="rounded-md border border-border/70 bg-background/70 p-2">
|
||||
<p className="text-[11px] font-semibold text-foreground/90">{section.label}</p>
|
||||
<p className="whitespace-pre-wrap break-words text-[12px] leading-relaxed text-foreground/90">
|
||||
@@ -234,41 +307,77 @@ export default function AgentOutputNode({ data, selected }: NodeProps<AgentOutpu
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{metadataEntries.length > 0 ? (
|
||||
<section data-testid="agent-output-metadata" className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("metadataLabel")}</p>
|
||||
<div className="space-y-1 text-[12px] text-foreground/90">
|
||||
{metadataEntries.map(([key, value]) => (
|
||||
<p key={key} className="break-words">
|
||||
<span className="font-semibold">{key}</span>: {value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{qualityChecks.length > 0 ? (
|
||||
<section data-testid="agent-output-quality-checks" className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("qualityChecksLabel")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{qualityChecks.map((qualityCheck) => (
|
||||
<span
|
||||
key={qualityCheck}
|
||||
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-200"
|
||||
{hasDetailsContent ? (
|
||||
<details data-testid="agent-output-details" className="rounded-md border border-border/70 bg-muted/30 px-2 py-1.5">
|
||||
<summary className="cursor-pointer text-[11px] font-semibold text-foreground/80">{t("detailsLabel")}</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{hasMetaValues ? (
|
||||
<section
|
||||
data-testid="agent-output-meta-strip"
|
||||
className="grid grid-cols-2 gap-2 rounded-md border border-border/70 bg-background/70 px-2 py-1.5"
|
||||
>
|
||||
{qualityCheck}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("channelLabel")}</p>
|
||||
<p className="truncate text-xs font-medium text-foreground/90" title={nodeData.channel}>
|
||||
{nodeData.channel ?? t("emptyValue")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("artifactTypeLabel")}</p>
|
||||
<p className="truncate text-xs font-medium text-foreground/90" title={artifactType}>
|
||||
{artifactType || t("emptyValue")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section data-testid="agent-output-preview" className="space-y-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("previewLabel")}</p>
|
||||
<div className="max-h-40 overflow-auto rounded-md border border-border/70 bg-background/70 p-3 text-[13px] leading-relaxed text-foreground/90">
|
||||
<p className="whitespace-pre-wrap break-words">{previewText || t("previewFallback")}</p>
|
||||
</div>
|
||||
</section>
|
||||
{secondarySections.length > 0 ? (
|
||||
<section data-testid="agent-output-secondary-sections" className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("sectionsLabel")}</p>
|
||||
<div className="space-y-1.5">
|
||||
{secondarySections.map((section) => (
|
||||
<div key={section.id} className="rounded-md border border-border/70 bg-background/70 p-2">
|
||||
<p className="text-[11px] font-semibold text-foreground/90">{section.label}</p>
|
||||
<p className="whitespace-pre-wrap break-words text-[12px] leading-relaxed text-foreground/90">
|
||||
{section.content}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{metadataEntries.length > 0 ? (
|
||||
<section data-testid="agent-output-metadata" className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("metadataLabel")}</p>
|
||||
<div className="space-y-1 text-[12px] text-foreground/90">
|
||||
{metadataEntries.map(([key, value]) => (
|
||||
<p key={key} className="break-words">
|
||||
<span className="font-semibold">{resolveMetadataLabel(key, metadataLabels)}</span>: {value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{qualityChecks.length > 0 ? (
|
||||
<section data-testid="agent-output-quality-checks" className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t("qualityChecksLabel")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{qualityChecks.map((qualityCheck) => (
|
||||
<span
|
||||
key={qualityCheck}
|
||||
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{qualityCheck}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</>
|
||||
) : formattedJsonBody ? (
|
||||
<section className="space-y-1">
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
resolveRenderPreviewInputFromGraph,
|
||||
type RenderPreviewInput,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import {
|
||||
resolveMixerPreviewFromGraph,
|
||||
type MixerPreviewState,
|
||||
} from "@/lib/canvas-mixer-preview";
|
||||
|
||||
interface CompareNodeData {
|
||||
leftUrl?: string;
|
||||
@@ -25,6 +29,7 @@ type CompareSideState = {
|
||||
finalUrl?: string;
|
||||
label?: string;
|
||||
previewInput?: RenderPreviewInput;
|
||||
mixerPreviewState?: MixerPreviewState;
|
||||
isStaleRenderOutput: boolean;
|
||||
};
|
||||
|
||||
@@ -59,6 +64,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
const label = finalLabel ?? sourceLabel ?? defaultLabel;
|
||||
|
||||
let previewInput: RenderPreviewInput | undefined;
|
||||
let mixerPreviewState: MixerPreviewState | undefined;
|
||||
let isStaleRenderOutput = false;
|
||||
|
||||
if (sourceNode && sourceNode.type === "render") {
|
||||
@@ -97,11 +103,36 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (finalUrl) {
|
||||
return { finalUrl, label, previewInput, isStaleRenderOutput };
|
||||
if (sourceNode && sourceNode.type === "mixer") {
|
||||
const mixerPreview = resolveMixerPreviewFromGraph({
|
||||
nodeId: sourceNode.id,
|
||||
graph,
|
||||
});
|
||||
|
||||
if (mixerPreview.status === "ready") {
|
||||
mixerPreviewState = mixerPreview;
|
||||
}
|
||||
}
|
||||
|
||||
return { label, previewInput, isStaleRenderOutput };
|
||||
const visibleFinalUrl =
|
||||
sourceNode?.type === "mixer" && mixerPreviewState ? undefined : finalUrl;
|
||||
|
||||
if (visibleFinalUrl) {
|
||||
return {
|
||||
finalUrl: visibleFinalUrl,
|
||||
label,
|
||||
previewInput,
|
||||
mixerPreviewState,
|
||||
isStaleRenderOutput,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
previewInput,
|
||||
mixerPreviewState,
|
||||
isStaleRenderOutput,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -117,8 +148,16 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
graph,
|
||||
]);
|
||||
|
||||
const hasLeft = Boolean(resolvedSides.left.finalUrl || resolvedSides.left.previewInput);
|
||||
const hasRight = Boolean(resolvedSides.right.finalUrl || resolvedSides.right.previewInput);
|
||||
const hasLeft = Boolean(
|
||||
resolvedSides.left.finalUrl ||
|
||||
resolvedSides.left.previewInput ||
|
||||
resolvedSides.left.mixerPreviewState,
|
||||
);
|
||||
const hasRight = Boolean(
|
||||
resolvedSides.right.finalUrl ||
|
||||
resolvedSides.right.previewInput ||
|
||||
resolvedSides.right.mixerPreviewState,
|
||||
);
|
||||
const hasConnectedRenderInput = useMemo(
|
||||
() =>
|
||||
incomingEdges.some((edge) => {
|
||||
@@ -273,6 +312,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
finalUrl={resolvedSides.right.finalUrl}
|
||||
label={resolvedSides.right.label}
|
||||
previewInput={resolvedSides.right.previewInput}
|
||||
mixerPreviewState={resolvedSides.right.mixerPreviewState}
|
||||
nodeWidth={previewNodeWidth}
|
||||
preferPreview={effectiveDisplayMode === "preview"}
|
||||
/>
|
||||
@@ -283,6 +323,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
|
||||
finalUrl={resolvedSides.left.finalUrl}
|
||||
label={resolvedSides.left.label}
|
||||
previewInput={resolvedSides.left.previewInput}
|
||||
mixerPreviewState={resolvedSides.left.mixerPreviewState}
|
||||
nodeWidth={previewNodeWidth}
|
||||
clipWidthPercent={sliderX}
|
||||
preferPreview={effectiveDisplayMode === "preview"}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
shouldFastPathPreviewPipeline,
|
||||
type RenderPreviewInput,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import type { MixerPreviewState } from "@/lib/canvas-mixer-preview";
|
||||
|
||||
const EMPTY_STEPS: RenderPreviewInput["steps"] = [];
|
||||
|
||||
@@ -13,6 +14,7 @@ type CompareSurfaceProps = {
|
||||
finalUrl?: string;
|
||||
label?: string;
|
||||
previewInput?: RenderPreviewInput;
|
||||
mixerPreviewState?: MixerPreviewState;
|
||||
nodeWidth: number;
|
||||
clipWidthPercent?: number;
|
||||
preferPreview?: boolean;
|
||||
@@ -22,6 +24,7 @@ export default function CompareSurface({
|
||||
finalUrl,
|
||||
label,
|
||||
previewInput,
|
||||
mixerPreviewState,
|
||||
nodeWidth,
|
||||
clipWidthPercent,
|
||||
preferPreview,
|
||||
@@ -52,6 +55,7 @@ export default function CompareSurface({
|
||||
});
|
||||
|
||||
const hasPreview = Boolean(usePreview && previewInput);
|
||||
const hasMixerPreview = mixerPreviewState?.status === "ready";
|
||||
const clipStyle =
|
||||
typeof clipWidthPercent === "number"
|
||||
? {
|
||||
@@ -75,6 +79,28 @@ export default function CompareSurface({
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
) : hasMixerPreview ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={mixerPreviewState.baseUrl}
|
||||
alt={label ?? "Comparison image"}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={mixerPreviewState.overlayUrl}
|
||||
alt={label ?? "Comparison image"}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
draggable={false}
|
||||
style={{
|
||||
mixBlendMode: mixerPreviewState.blendMode,
|
||||
opacity: mixerPreviewState.opacity / 100,
|
||||
transform: `translate(${mixerPreviewState.offsetX}px, ${mixerPreviewState.offsetY}px)`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{hasPreview ? (
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function ImageNode({
|
||||
height,
|
||||
}: NodeProps<ImageNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const tMedia = useTranslations("mediaLibrary.imageNode");
|
||||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
|
||||
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
||||
@@ -377,7 +378,7 @@ export default function ImageNode({
|
||||
}
|
||||
|
||||
if (item.kind !== "image" || !item.storageId) {
|
||||
toast.error(t('canvas.uploadFailed'), "Nur Bilddateien mit Storage-ID koennen uebernommen werden.");
|
||||
toast.error(t('canvas.uploadFailed'), tMedia("invalidSelection"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,7 +428,7 @@ export default function ImageNode({
|
||||
);
|
||||
}
|
||||
},
|
||||
[data, id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t],
|
||||
[data, id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t, tMedia],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -586,7 +587,9 @@ export default function ImageNode({
|
||||
disabled={isNodeLoading || !isNodeStable}
|
||||
className="nodrag mt-3 inline-flex items-center rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isNodeStable ? "Aus Mediathek" : "Mediathek wird vorbereitet..."}
|
||||
{isNodeStable
|
||||
? tMedia("openButton")
|
||||
: tMedia("preparingButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -650,7 +653,7 @@ export default function ImageNode({
|
||||
onOpenChange={setIsMediaLibraryOpen}
|
||||
onPick={handlePickFromMediaLibrary}
|
||||
kindFilter="image"
|
||||
pickCtaLabel="Uebernehmen"
|
||||
pickCtaLabel={tMedia("pickCta")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
189
components/canvas/nodes/mixer-node.tsx
Normal file
189
components/canvas/nodes/mixer-node.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import {
|
||||
normalizeMixerPreviewData,
|
||||
resolveMixerPreviewFromGraph,
|
||||
type MixerBlendMode,
|
||||
} from "@/lib/canvas-mixer-preview";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
|
||||
const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"];
|
||||
|
||||
export default function MixerNode({ id, data, selected }: NodeProps) {
|
||||
const graph = useCanvasGraph();
|
||||
const { queueNodeDataUpdate } = useCanvasSync();
|
||||
const [hasImageLoadError, setHasImageLoadError] = useState(false);
|
||||
|
||||
const normalizedData = useMemo(() => normalizeMixerPreviewData(data), [data]);
|
||||
const previewState = useMemo(
|
||||
() => resolveMixerPreviewFromGraph({ nodeId: id, graph }),
|
||||
[graph, id],
|
||||
);
|
||||
|
||||
const currentData = (data ?? {}) as Record<string, unknown>;
|
||||
|
||||
const updateData = (patch: Partial<ReturnType<typeof normalizeMixerPreviewData>>) => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: {
|
||||
...currentData,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onBlendModeChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setHasImageLoadError(false);
|
||||
updateData({ blendMode: event.target.value as MixerBlendMode });
|
||||
};
|
||||
|
||||
const onNumberChange = (field: "opacity" | "offsetX" | "offsetY") => (
|
||||
event: FormEvent<HTMLInputElement>,
|
||||
) => {
|
||||
setHasImageLoadError(false);
|
||||
const nextValue = Number(event.currentTarget.value);
|
||||
updateData({ [field]: Number.isFinite(nextValue) ? nextValue : 0 });
|
||||
};
|
||||
|
||||
const showReadyPreview = previewState.status === "ready" && !hasImageLoadError;
|
||||
const showPreviewError = hasImageLoadError || previewState.status === "error";
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="mixer" selected={selected} className="p-0">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="base"
|
||||
style={{ top: "35%" }}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-sky-500"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="overlay"
|
||||
style={{ top: "58%" }}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-pink-500"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="mixer-out"
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-muted-foreground"
|
||||
/>
|
||||
|
||||
<div className="grid h-full w-full grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
Mixer
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[140px] overflow-hidden bg-muted/40">
|
||||
{showReadyPreview ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewState.baseUrl}
|
||||
alt="Mixer base"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
draggable={false}
|
||||
onError={() => setHasImageLoadError(true)}
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewState.overlayUrl}
|
||||
alt="Mixer overlay"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
draggable={false}
|
||||
onError={() => setHasImageLoadError(true)}
|
||||
style={{
|
||||
mixBlendMode: previewState.blendMode,
|
||||
opacity: previewState.opacity / 100,
|
||||
transform: `translate(${previewState.offsetX}px, ${previewState.offsetY}px)`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{previewState.status === "empty" && !showPreviewError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-5 text-center text-xs text-muted-foreground">
|
||||
Connect base and overlay images
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{previewState.status === "partial" && !showPreviewError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-5 text-center text-xs text-muted-foreground">
|
||||
Waiting for second input
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showPreviewError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-5 text-center text-xs text-red-600">
|
||||
Preview unavailable
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border p-2 text-[11px]">
|
||||
<label className="col-span-2 flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Blend mode</span>
|
||||
<select
|
||||
name="blendMode"
|
||||
value={normalizedData.blendMode}
|
||||
onChange={onBlendModeChange}
|
||||
className="nodrag h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
>
|
||||
{BLEND_MODE_OPTIONS.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Opacity</span>
|
||||
<input
|
||||
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
type="number"
|
||||
name="opacity"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={normalizedData.opacity}
|
||||
onInput={onNumberChange("opacity")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Offset X</span>
|
||||
<input
|
||||
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
type="number"
|
||||
name="offsetX"
|
||||
step={1}
|
||||
value={normalizedData.offsetX}
|
||||
onInput={onNumberChange("offsetX")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="col-span-2 flex flex-col gap-1 text-muted-foreground">
|
||||
<span>Offset Y</span>
|
||||
<input
|
||||
className="nodrag nowheel h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
|
||||
type="number"
|
||||
name="offsetY"
|
||||
step={1}
|
||||
value={normalizedData.offsetY}
|
||||
onInput={onNumberChange("offsetY")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</BaseNodeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ type UseCanvasConnectionsParams = {
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
}) => Promise<unknown>;
|
||||
runSplitEdgeAtExistingNodeMutation: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -61,6 +62,11 @@ type UseCanvasConnectionsParams = {
|
||||
newNodeTargetHandle?: string;
|
||||
}) => Promise<unknown>;
|
||||
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||
runSwapMixerInputsMutation: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
}) => Promise<unknown>;
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
type: CanvasNodeType;
|
||||
@@ -113,6 +119,7 @@ export function useCanvasConnections({
|
||||
runRemoveEdgeMutation,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||
runSwapMixerInputsMutation,
|
||||
showConnectionRejectedToast,
|
||||
}: UseCanvasConnectionsParams) {
|
||||
const [connectionDropMenu, setConnectionDropMenu] =
|
||||
@@ -178,6 +185,70 @@ export function useCanvasConnections({
|
||||
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
|
||||
);
|
||||
|
||||
const resolveMixerSwapReconnect = useCallback(
|
||||
(oldEdge: RFEdge, newConnection: Connection, validationError: string) => {
|
||||
if (validationError !== "mixer-handle-incoming-limit") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!newConnection.target || oldEdge.target !== newConnection.target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetNode = nodes.find((node) => node.id === newConnection.target);
|
||||
if (!targetNode || targetNode.type !== "mixer") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizeMixerHandle = (handle: string | null | undefined): "base" | "overlay" | null => {
|
||||
if (handle == null || handle === "" || handle === "null") {
|
||||
return "base";
|
||||
}
|
||||
if (handle === "base" || handle === "overlay") {
|
||||
return handle;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const oldHandle = normalizeMixerHandle(oldEdge.targetHandle);
|
||||
const requestedHandle = normalizeMixerHandle(newConnection.targetHandle);
|
||||
if (!oldHandle || !requestedHandle || oldHandle === requestedHandle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mixerIncomingEdges = edges.filter(
|
||||
(edge) =>
|
||||
edge.className !== "temp" &&
|
||||
!isOptimisticEdgeId(edge.id) &&
|
||||
edge.target === newConnection.target,
|
||||
);
|
||||
|
||||
if (mixerIncomingEdges.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const otherEdge = mixerIncomingEdges.find(
|
||||
(candidate) => candidate.id !== oldEdge.id,
|
||||
);
|
||||
if (!otherEdge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const otherHandle = normalizeMixerHandle(otherEdge.targetHandle);
|
||||
if (!otherHandle || otherHandle !== requestedHandle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
edgeId: oldEdge.id as Id<"edges">,
|
||||
otherEdgeId: otherEdge.id as Id<"edges">,
|
||||
nextEdgeHandle: requestedHandle,
|
||||
nextOtherEdgeHandle: oldHandle,
|
||||
};
|
||||
},
|
||||
[edges, nodes],
|
||||
);
|
||||
|
||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||
(event, connectionState) => {
|
||||
if (!isConnectDragActiveRef.current) {
|
||||
@@ -438,6 +509,7 @@ export function useCanvasConnections({
|
||||
sourceType: fromNode.type ?? "",
|
||||
targetType: template.type,
|
||||
targetNodeId: `__pending_${template.type}_${Date.now()}`,
|
||||
targetHandle: handles?.target,
|
||||
edges: edgesRef.current,
|
||||
});
|
||||
if (validationError) {
|
||||
@@ -469,6 +541,7 @@ export function useCanvasConnections({
|
||||
sourceType: template.type,
|
||||
targetType: fromNode.type ?? "",
|
||||
targetNodeId: fromNode.id,
|
||||
targetHandle: ctx.fromHandleId,
|
||||
edges: edgesRef.current,
|
||||
});
|
||||
if (validationError) {
|
||||
@@ -518,8 +591,10 @@ export function useCanvasConnections({
|
||||
setEdges,
|
||||
runCreateEdgeMutation,
|
||||
runRemoveEdgeMutation,
|
||||
runSwapMixerInputsMutation,
|
||||
validateConnection: (oldEdge, nextConnection) =>
|
||||
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
|
||||
resolveMixerSwapReconnect,
|
||||
onInvalidConnection: (reason) => {
|
||||
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user