feat(canvas): finalize mixer reconnect swap and related updates
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user