feat(canvas): finalize mixer reconnect swap and related updates
This commit is contained in:
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