"use client"; import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type MouseEvent as ReactMouseEvent, } from "react"; import { Handle, Position, type NodeProps } from "@xyflow/react"; import BaseNodeWrapper from "./base-node-wrapper"; import { useNodeLocalData } from "./use-node-local-data"; 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"]; const MIN_OVERLAY_SIZE = 0.1; const MAX_OVERLAY_POSITION = 1; const SAVE_DELAY_MS = 160; type MixerLocalData = ReturnType; type ResizeCorner = "nw" | "ne" | "sw" | "se"; type InteractionState = | { kind: "move"; startClientX: number; startClientY: number; startData: MixerLocalData; previewWidth: number; previewHeight: number; } | { kind: "resize"; corner: ResizeCorner; startClientX: number; startClientY: number; startData: MixerLocalData; previewWidth: number; previewHeight: number; }; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function normalizeLocalMixerData(data: MixerLocalData): MixerLocalData { const overlayX = clamp(data.overlayX, 0, MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE); const overlayY = clamp(data.overlayY, 0, MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE); const overlayWidth = clamp(data.overlayWidth, MIN_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayX); const overlayHeight = clamp(data.overlayHeight, MIN_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayY); return { ...data, overlayX, overlayY, overlayWidth, overlayHeight, }; } function computeResizeRect(args: { startData: MixerLocalData; corner: ResizeCorner; deltaX: number; deltaY: number; }): Pick { const { startData, corner, deltaX, deltaY } = args; const startRight = startData.overlayX + startData.overlayWidth; const startBottom = startData.overlayY + startData.overlayHeight; let overlayX = startData.overlayX; let overlayY = startData.overlayY; let overlayWidth = startData.overlayWidth; let overlayHeight = startData.overlayHeight; if (corner.includes("w")) { overlayX = clamp( startData.overlayX + deltaX, 0, startData.overlayX + startData.overlayWidth - MIN_OVERLAY_SIZE, ); overlayWidth = startRight - overlayX; } if (corner.includes("e")) { overlayWidth = clamp( startData.overlayWidth + deltaX, MIN_OVERLAY_SIZE, MAX_OVERLAY_POSITION - startData.overlayX, ); } if (corner.includes("n")) { overlayY = clamp( startData.overlayY + deltaY, 0, startData.overlayY + startData.overlayHeight - MIN_OVERLAY_SIZE, ); overlayHeight = startBottom - overlayY; } if (corner.includes("s")) { overlayHeight = clamp( startData.overlayHeight + deltaY, MIN_OVERLAY_SIZE, MAX_OVERLAY_POSITION - startData.overlayY, ); } return normalizeLocalMixerData({ ...startData, overlayX, overlayY, overlayWidth, overlayHeight, }); } export default function MixerNode({ id, data, selected }: NodeProps) { const graph = useCanvasGraph(); const { queueNodeDataUpdate } = useCanvasSync(); const previewRef = useRef(null); const latestNodeDataRef = useRef((data ?? {}) as Record); const [hasImageLoadError, setHasImageLoadError] = useState(false); const [interaction, setInteraction] = useState(null); useEffect(() => { latestNodeDataRef.current = (data ?? {}) as Record; }, [data]); const { localData, updateLocalData } = useNodeLocalData({ nodeId: id, data, normalize: normalizeMixerPreviewData, saveDelayMs: SAVE_DELAY_MS, onSave: (next) => queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...latestNodeDataRef.current, ...next, }, }), debugLabel: "mixer", }); const previewState = useMemo( () => resolveMixerPreviewFromGraph({ nodeId: id, graph }), [graph, id], ); const onBlendModeChange = (event: ChangeEvent) => { setHasImageLoadError(false); updateLocalData((current) => ({ ...current, blendMode: event.target.value as MixerBlendMode, })); }; const onNumberChange = ( field: "opacity" | "overlayX" | "overlayY" | "overlayWidth" | "overlayHeight", ) => (event: FormEvent) => { setHasImageLoadError(false); const nextValue = Number(event.currentTarget.value); updateLocalData((current) => { if (!Number.isFinite(nextValue)) { return current; } if (field === "opacity") { return { ...current, opacity: clamp(nextValue, 0, 100), }; } return normalizeLocalMixerData({ ...current, [field]: nextValue, }); }); }; const startInteraction = ( event: ReactMouseEvent, kind: InteractionState["kind"], corner?: ResizeCorner, ) => { event.preventDefault(); event.stopPropagation(); const previewRect = previewRef.current?.getBoundingClientRect(); if (!previewRect || previewRect.width <= 0 || previewRect.height <= 0) { return; } setInteraction({ kind, corner: kind === "resize" ? (corner as ResizeCorner) : undefined, startClientX: event.clientX, startClientY: event.clientY, startData: localData, previewWidth: previewRect.width, previewHeight: previewRect.height, } as InteractionState); }; useEffect(() => { if (!interaction) { return; } const handleMouseMove = (event: MouseEvent) => { const deltaX = (event.clientX - interaction.startClientX) / interaction.previewWidth; const deltaY = (event.clientY - interaction.startClientY) / interaction.previewHeight; if (interaction.kind === "move") { const nextX = clamp( interaction.startData.overlayX + deltaX, 0, MAX_OVERLAY_POSITION - interaction.startData.overlayWidth, ); const nextY = clamp( interaction.startData.overlayY + deltaY, 0, MAX_OVERLAY_POSITION - interaction.startData.overlayHeight, ); updateLocalData((current) => ({ ...current, overlayX: nextX, overlayY: nextY, })); return; } const nextRect = computeResizeRect({ startData: interaction.startData, corner: interaction.corner, deltaX, deltaY, }); updateLocalData((current) => ({ ...current, ...nextRect, })); }; const handleMouseUp = () => { setInteraction(null); }; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [interaction, updateLocalData]); const showReadyPreview = previewState.status === "ready" && !hasImageLoadError; const showPreviewError = hasImageLoadError || previewState.status === "error"; const overlayStyle = { mixBlendMode: localData.blendMode, opacity: localData.opacity / 100, left: `${localData.overlayX * 100}%`, top: `${localData.overlayY * 100}%`, width: `${localData.overlayWidth * 100}%`, height: `${localData.overlayHeight * 100}%`, } as const; return (
Mixer
{showReadyPreview ? ( <> {/* eslint-disable-next-line @next/next/no-img-element */} Mixer base setHasImageLoadError(true)} /> {/* eslint-disable-next-line @next/next/no-img-element */} Mixer overlay startInteraction(event, "move")} onError={() => setHasImageLoadError(true)} style={overlayStyle} /> {([ { corner: "nw", cursor: "nwse-resize" }, { corner: "ne", cursor: "nesw-resize" }, { corner: "sw", cursor: "nesw-resize" }, { corner: "se", cursor: "nwse-resize" }, ] as const).map(({ corner, cursor }) => (
startInteraction(event, "resize", corner)} style={{ left: `${(corner.includes("w") ? localData.overlayX : localData.overlayX + localData.overlayWidth) * 100}%`, top: `${(corner.includes("n") ? localData.overlayY : localData.overlayY + localData.overlayHeight) * 100}%`, transform: "translate(-50%, -50%)", cursor, }} /> ))} ) : null} {previewState.status === "empty" && !showPreviewError ? (
Connect base and overlay images
) : null} {previewState.status === "partial" && !showPreviewError ? (
Waiting for second input
) : null} {showPreviewError ? (
Preview unavailable
) : null}
); }