feat(canvas): finalize mixer reconnect swap and related updates
This commit is contained in:
133
components/agents/growth-hacker.md
Normal file
133
components/agents/growth-hacker.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
name: Growth Hacker
|
||||
description: Turns a product brief, campaign assets, and existing content into a prioritised growth strategy and a concrete experiment backlog — scoped to available time and resources.
|
||||
tools: WebFetch, WebSearch, Read, Write, Edit
|
||||
color: green
|
||||
emoji: seedling
|
||||
vibe: Finds the highest-leverage move given what you already have — then tells you exactly how to run it.
|
||||
---
|
||||
|
||||
# Growth Hacker Agent
|
||||
|
||||
## Prompt Segments (Compiled)
|
||||
|
||||
This document has a dual role in the agent runtime:
|
||||
|
||||
1. Product and behavior reference for humans.
|
||||
2. Source for prompt segment extraction during compile time.
|
||||
|
||||
Only explicitly marked `AGENT_PROMPT_SEGMENT` blocks are compiled into runtime prompt input.
|
||||
Unmarked prose in this file stays documentation-only and is not injected into model prompts.
|
||||
|
||||
<!-- AGENT_PROMPT_SEGMENT:role:start -->
|
||||
You are the Growth Hacker for LemonSpace, an AI creative canvas used by small design and marketing teams. Your mission is to turn a product brief, canvas assets, and optional existing content into two sequential outputs: (1) a focused growth strategy with funnel diagnosis and channel priorities, and (2) a prioritised experiment backlog where every experiment is concrete, scoped to the operator's real resources, and immediately executable.
|
||||
|
||||
LemonSpace context you must internalize: Primary ICP is 2–10 person design and marketing teams. Monetisation runs on a credit system (Free 50 Cr → Starter €8 → Pro €59 → Max €119). The North Star Metric is weekly active workspaces with at least one exported canvas output. Key growth levers are: free-tier credit exhaustion as natural upgrade trigger, canvas outputs users want to share organically, and self-hosting community effects (GitHub, Hacker News, r/selfhosted). Never recommend forced virality, aggressive upsell modals, or growth that destroys COGS margin.
|
||||
<!-- AGENT_PROMPT_SEGMENT:role:end -->
|
||||
|
||||
<!-- AGENT_PROMPT_SEGMENT:style-rules:start -->
|
||||
Write specific, decisive, and immediately actionable recommendations. Every experiment must have a falsifiable hypothesis, a single primary metric, a success threshold, and a realistic effort estimate. Prefer concrete imperatives over vague directives — "publish a Show HN post on Tuesday 9am ET" not "consider posting on Hacker News". Keep claims honest: never invent platform benchmarks or competitor data. Label any external benchmark you use as an industry reference, not a LemonSpace data point. When inputs are sparse, surface assumptions explicitly rather than pretending certainty.
|
||||
<!-- AGENT_PROMPT_SEGMENT:style-rules:end -->
|
||||
|
||||
<!-- AGENT_PROMPT_SEGMENT:decision-framework:start -->
|
||||
Reason in this order: (1) detect language from brief and default to English if ambiguous, (2) diagnose the funnel stage with the biggest leverage gap (awareness / activation / retention / monetisation / virality), (3) assess which provided assets and content nodes can be used directly — never recommend creating new assets from scratch if suitable ones exist, (4) prioritise experiments by ICE score (Impact × Confidence × Ease), weighting Ease 1.5× when resources are set to solo, (5) scope every experiment to the declared timeframe and resource level — no experiment may exceed one working day of setup for solo operators, (6) produce the strategy output first, then the experiment backlog. Surface all assumptions and constraints explicitly in each output.
|
||||
<!-- AGENT_PROMPT_SEGMENT:decision-framework:end -->
|
||||
|
||||
<!-- AGENT_PROMPT_SEGMENT:experiment-rules:start -->
|
||||
Each experiment must specify: a one-sentence hypothesis in "If we [change], then [metric] will [direction] by [estimate], because [assumption]" format; the channel or product surface it runs on; which provided asset or content node it uses (or "none"); three to five concrete action steps; effort in hours; run duration with minimum sample size; primary metric; success threshold; failure threshold; and ICE scores. If focus includes virality, include at least one experiment leveraging canvas output sharing. If focus includes monetisation, include at least one experiment targeting the credit exhaustion upgrade moment. Never include experiments requiring external team members when resources is solo. Never recommend watermarks, mandatory social shares, or modal interruptions during canvas export flow.
|
||||
<!-- AGENT_PROMPT_SEGMENT:experiment-rules:end -->
|
||||
|
||||
## Runtime Contract Snapshot
|
||||
|
||||
This agent is wired through two contracts that must stay in sync:
|
||||
|
||||
- Structural/runtime contract in TypeScript (`lib/agent-definitions.ts`, `lib/agent-run-contract.ts`).
|
||||
- Curated prompt influence from compiled markdown segments (`scripts/compile-agent-docs.ts` -> `lib/generated/agent-doc-segments.ts`).
|
||||
|
||||
`convex/agents.ts` consumes generated segments through `lib/agent-prompting.ts`. It does not parse markdown at runtime.
|
||||
|
||||
### Output shape expectations
|
||||
|
||||
Strategy output is expected to provide:
|
||||
|
||||
- `artifactType: "growth-strategy"`
|
||||
- `previewText`: one-sentence summary of the primary growth lever identified
|
||||
- `sections[]` with `id`, `label`, `content` — covering: summary, north star, funnel diagnosis, channel priorities, asset assessment, assumptions
|
||||
- `metadata` as `Record<string, string | string[]>`
|
||||
- `qualityChecks[]`
|
||||
|
||||
Each experiment output is expected to provide:
|
||||
|
||||
- `artifactType: "growth-experiment"`
|
||||
- `previewText`: the hypothesis sentence
|
||||
- `sections[]` — covering: hypothesis, channel/surface, asset used, actions, metric, thresholds
|
||||
- `metadata`: ICE scores, effort hours, run duration, focus area
|
||||
- `qualityChecks[]`
|
||||
|
||||
`body` remains a compatibility fallback for older render paths.
|
||||
|
||||
## Node Purpose
|
||||
|
||||
The node takes a product brief plus optional image assets and content nodes, and emits one strategy `agent-output` node followed by three to seven experiment `agent-output` nodes, ordered by ICE score.
|
||||
It does not emit raw text blobs as primary output.
|
||||
|
||||
## Canonical Inputs
|
||||
|
||||
| Handle | Source types | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `brief` | `text`, `note` | yes | Product/campaign description, target audience, growth goal, constraints, budget. |
|
||||
| `assets` | `image`, `ai-image`, `render`, `compare` | no | Canvas outputs available as campaign assets. |
|
||||
| `content` | `ai-text`, `agent-output` | no | Existing content nodes, e.g. output from Campaign Distributor. |
|
||||
|
||||
If assets or content nodes are connected, the agent must use them in at least two experiments. If brief is sparse, infer from asset labels and content and write assumptions explicitly.
|
||||
|
||||
## Operator Parameters (Definition Layer)
|
||||
|
||||
- `timeframe`: `2_weeks | 1_month | 3_months`
|
||||
- `resources`: `solo | small_team | with_budget`
|
||||
- `focus`: multi-select — `acquisition | activation | retention | monetisation | virality`
|
||||
|
||||
## Analyze Stage Responsibilities
|
||||
|
||||
Before execute, the agent should build a plan that includes:
|
||||
|
||||
- funnel stage with highest leverage gap
|
||||
- channel priorities with rationale
|
||||
- asset-to-experiment mapping
|
||||
- experiment count and focus areas
|
||||
- language detection result
|
||||
- explicit assumptions list
|
||||
|
||||
## Execute Stage Responsibilities
|
||||
|
||||
For the strategy output, the agent should produce:
|
||||
|
||||
- plain-language growth summary
|
||||
- north star metric statement with baseline if inferable
|
||||
- per-funnel-stage assessment and biggest opportunity
|
||||
- channel priority list with effort/impact rationale
|
||||
- assessment of provided assets and how to deploy them
|
||||
|
||||
For each experiment output, the agent should produce:
|
||||
|
||||
- falsifiable hypothesis
|
||||
- concrete action steps (imperative, not advisory)
|
||||
- single primary metric with success and failure thresholds
|
||||
- ICE score breakdown
|
||||
- explicit note on which provided asset or content node is used
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not fabricate metrics, platform statistics, or competitor positioning.
|
||||
- Every experiment must be executable within declared resource constraints.
|
||||
- Never recommend more than three simultaneous experiments for solo operators.
|
||||
- When context is missing, expose assumptions instead of pretending certainty.
|
||||
- Do not recommend growth tactics that conflict with LemonSpace's BSL 1.1 licence positioning or its source-available community trust.
|
||||
|
||||
## Human Reference Examples
|
||||
|
||||
- "Analyse these 4 campaign variants and tell me which growth experiments to run first."
|
||||
- "I have a Campaign Distributor output for Instagram and LinkedIn — what should I test?"
|
||||
- "Solo founder, 1 month, €0 budget — give me the highest-leverage experiments."
|
||||
- "My activation rate is low. Diagnose the funnel and give me 3 experiments."
|
||||
- "We're about to launch on ProductHunt — build me the experiment plan around it."
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -7,8 +7,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useAuthQuery: vi.fn(),
|
||||
resolveUrls: vi.fn(async () => ({})),
|
||||
useTranslations: vi.fn(),
|
||||
}));
|
||||
|
||||
const translations = {
|
||||
previous: "Zurueck",
|
||||
next: "Weiter",
|
||||
pageOf: "Seite {page} von {totalPages}",
|
||||
} as const;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: () => mocks.resolveUrls,
|
||||
}));
|
||||
@@ -17,6 +24,10 @@ vi.mock("@/hooks/use-auth-query", () => ({
|
||||
useAuthQuery: (...args: unknown[]) => mocks.useAuthQuery(...args),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (...args: unknown[]) => mocks.useTranslations(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
@@ -49,7 +60,23 @@ describe("MediaLibraryDialog", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useAuthQuery.mockReset();
|
||||
mocks.resolveUrls.mockReset();
|
||||
mocks.useTranslations.mockReset();
|
||||
mocks.resolveUrls.mockImplementation(async () => ({}));
|
||||
const translate = (
|
||||
key: keyof typeof translations,
|
||||
values?: Record<string, string | number>,
|
||||
) => {
|
||||
const template = translations[key] ?? key;
|
||||
if (!values) {
|
||||
return template;
|
||||
}
|
||||
|
||||
return template.replace(/\{(\w+)\}/g, (_, token: string) => {
|
||||
const value = values[token];
|
||||
return value === undefined ? `{${token}}` : String(value);
|
||||
});
|
||||
};
|
||||
mocks.useTranslations.mockReturnValue(translate);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -86,7 +113,7 @@ describe("MediaLibraryDialog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders at most 8 cards and shows Freepik-style pagination footer", async () => {
|
||||
it("renders at most 8 cards and shows localized pagination footer", async () => {
|
||||
mocks.useAuthQuery.mockReturnValue({
|
||||
items: makeItems(10),
|
||||
page: 1,
|
||||
@@ -102,9 +129,9 @@ describe("MediaLibraryDialog", () => {
|
||||
const cards = document.querySelectorAll("img[alt^='Item 1-']");
|
||||
expect(cards).toHaveLength(8);
|
||||
|
||||
expect(document.body.textContent).toContain("Previous");
|
||||
expect(document.body.textContent).toContain("Page 1 of 3");
|
||||
expect(document.body.textContent).toContain("Next");
|
||||
expect(document.body.textContent).toContain("Zurueck");
|
||||
expect(document.body.textContent).toContain("Seite 1 von 3");
|
||||
expect(document.body.textContent).toContain("Weiter");
|
||||
});
|
||||
|
||||
it("updates query args when clicking next and previous", async () => {
|
||||
@@ -135,7 +162,7 @@ describe("MediaLibraryDialog", () => {
|
||||
});
|
||||
|
||||
const nextButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Next",
|
||||
(button) => button.textContent?.trim() === "Weiter",
|
||||
);
|
||||
if (!(nextButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Next button not found");
|
||||
@@ -149,7 +176,7 @@ describe("MediaLibraryDialog", () => {
|
||||
expect(nextCallArgs).toEqual(expect.objectContaining({ page: 2, pageSize: 8 }));
|
||||
|
||||
const previousButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Previous",
|
||||
(button) => button.textContent?.trim() === "Zurueck",
|
||||
);
|
||||
if (!(previousButton instanceof HTMLButtonElement)) {
|
||||
throw new Error("Previous button not found");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { AlertCircle, Box, ImageIcon, Loader2, Video } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -73,15 +74,18 @@ function formatDimensions(width: number | undefined, height: number | undefined)
|
||||
return `${width} x ${height}px`;
|
||||
}
|
||||
|
||||
function formatMediaMeta(item: MediaLibraryItem): string {
|
||||
function formatMediaMeta(
|
||||
item: MediaLibraryItem,
|
||||
tCommon: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
if (item.kind === "video") {
|
||||
if (typeof item.durationSeconds === "number" && Number.isFinite(item.durationSeconds)) {
|
||||
return `${Math.max(1, Math.round(item.durationSeconds))}s`;
|
||||
}
|
||||
return "Videodatei";
|
||||
return tCommon("videoFile");
|
||||
}
|
||||
|
||||
return formatDimensions(item.width, item.height) ?? "Groesse unbekannt";
|
||||
return formatDimensions(item.width, item.height) ?? tCommon("unknownSize");
|
||||
}
|
||||
|
||||
function getItemKey(item: MediaLibraryItem): string {
|
||||
@@ -104,32 +108,37 @@ function getItemKey(item: MediaLibraryItem): string {
|
||||
return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`;
|
||||
}
|
||||
|
||||
function getItemLabel(item: MediaLibraryItem): string {
|
||||
function getItemLabel(
|
||||
item: MediaLibraryItem,
|
||||
tCommon: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
if (item.filename) {
|
||||
return item.filename;
|
||||
}
|
||||
|
||||
if (item.kind === "video") {
|
||||
return "Unbenanntes Video";
|
||||
return tCommon("untitledVideo");
|
||||
}
|
||||
|
||||
if (item.kind === "asset") {
|
||||
return "Unbenanntes Asset";
|
||||
return tCommon("untitledAsset");
|
||||
}
|
||||
|
||||
return "Unbenanntes Bild";
|
||||
return tCommon("untitledImage");
|
||||
}
|
||||
|
||||
export function MediaLibraryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onPick,
|
||||
title = "Mediathek",
|
||||
title,
|
||||
description,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
kindFilter,
|
||||
pickCtaLabel = "Auswaehlen",
|
||||
pickCtaLabel,
|
||||
}: MediaLibraryDialogProps) {
|
||||
const tDialog = useTranslations("mediaLibrary.dialog");
|
||||
const tCommon = useTranslations("mediaLibrary.common");
|
||||
const [page, setPage] = useState(1);
|
||||
const normalizedPageSize = useMemo(() => {
|
||||
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
|
||||
@@ -203,7 +212,7 @@ export function MediaLibraryDialog({
|
||||
return;
|
||||
}
|
||||
setUrlMap({});
|
||||
setUrlError(error instanceof Error ? error.message : "URLs konnten nicht geladen werden.");
|
||||
setUrlError(error instanceof Error ? error.message : tDialog("urlResolveError"));
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsResolvingUrls(false);
|
||||
@@ -216,7 +225,7 @@ export function MediaLibraryDialog({
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [metadata, open, resolveUrls]);
|
||||
}, [metadata, open, resolveUrls, tDialog]);
|
||||
|
||||
const items: MediaLibraryItem[] = useMemo(() => {
|
||||
if (!metadata) {
|
||||
@@ -229,16 +238,25 @@ export function MediaLibraryDialog({
|
||||
}));
|
||||
}, [metadata, urlMap]);
|
||||
|
||||
const visibleItems = useMemo(() => items.slice(0, DEFAULT_PAGE_SIZE), [items]);
|
||||
const visibleItems = useMemo(
|
||||
() => items.slice(0, normalizedPageSize),
|
||||
[items, normalizedPageSize],
|
||||
);
|
||||
|
||||
const isMetadataLoading = open && metadata === undefined;
|
||||
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
|
||||
const isPreviewMode = typeof onPick !== "function";
|
||||
const effectiveTitle = title ?? tDialog("title");
|
||||
const effectivePickCtaLabel = pickCtaLabel ?? tDialog("pick");
|
||||
const effectiveDescription =
|
||||
description ??
|
||||
(kindFilter === "image"
|
||||
? "Waehle ein Bild aus deiner LemonSpace-Mediathek."
|
||||
: "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen.");
|
||||
? tDialog("descriptionImage")
|
||||
: kindFilter === "video"
|
||||
? tDialog("descriptionVideo")
|
||||
: kindFilter === "asset"
|
||||
? tDialog("descriptionAsset")
|
||||
: tDialog("descriptionDefault"));
|
||||
|
||||
async function handlePick(item: MediaLibraryItem): Promise<void> {
|
||||
if (!onPick || pendingPickItemKey) {
|
||||
@@ -257,7 +275,7 @@ export function MediaLibraryDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogTitle>{effectiveTitle}</DialogTitle>
|
||||
<DialogDescription>{effectiveDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -277,15 +295,15 @@ export function MediaLibraryDialog({
|
||||
) : urlError ? (
|
||||
<div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm font-medium">Mediathek konnte nicht geladen werden</p>
|
||||
<p className="text-sm font-medium">{tDialog("errorTitle")}</p>
|
||||
<p className="max-w-md text-xs text-muted-foreground">{urlError}</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Keine Medien vorhanden</p>
|
||||
<p className="text-sm font-medium">{tDialog("emptyTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sobald du Medien hochlaedst oder generierst, erscheinen sie hier.
|
||||
{tDialog("emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -293,8 +311,8 @@ export function MediaLibraryDialog({
|
||||
{visibleItems.map((item) => {
|
||||
const itemKey = getItemKey(item);
|
||||
const isPickingThis = pendingPickItemKey === itemKey;
|
||||
const itemLabel = getItemLabel(item);
|
||||
const metaLabel = formatMediaMeta(item);
|
||||
const itemLabel = getItemLabel(item, tCommon);
|
||||
const metaLabel = formatMediaMeta(item, tCommon);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -340,7 +358,7 @@ export function MediaLibraryDialog({
|
||||
</p>
|
||||
|
||||
{isPreviewMode ? (
|
||||
<p className="mt-auto text-[11px] text-muted-foreground">Nur Vorschau</p>
|
||||
<p className="mt-auto text-[11px] text-muted-foreground">{tDialog("previewOnly")}</p>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -352,10 +370,10 @@ export function MediaLibraryDialog({
|
||||
{isPickingThis ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
Wird uebernommen...
|
||||
{tDialog("pickLoading")}
|
||||
</>
|
||||
) : (
|
||||
pickCtaLabel
|
||||
effectivePickCtaLabel
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -375,10 +393,10 @@ export function MediaLibraryDialog({
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
{tDialog("previous")}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Page {metadata.page} of {metadata.totalPages}
|
||||
{tDialog("pageOf", { page: metadata.page, totalPages: metadata.totalPages })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -386,7 +404,7 @@ export function MediaLibraryDialog({
|
||||
onClick={() => setPage((current) => Math.min(metadata.totalPages, current + 1))}
|
||||
disabled={page >= metadata.totalPages}
|
||||
>
|
||||
Next
|
||||
{tDialog("next")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user