diff --git a/components/canvas/__tests__/canvas-connection-drop-target.test.tsx b/components/canvas/__tests__/canvas-connection-drop-target.test.tsx new file mode 100644 index 0000000..4139042 --- /dev/null +++ b/components/canvas/__tests__/canvas-connection-drop-target.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; + +import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpers"; + +function createNode(overrides: Partial & Pick): RFNode { + return { + id: overrides.id, + position: { x: 0, y: 0 }, + data: {}, + ...overrides, + } as RFNode; +} + +function createEdge( + overrides: Partial & Pick, +): RFEdge { + return { + ...overrides, + } as RFEdge; +} + +function makeNodeElement(id: string, rect: Partial = {}): HTMLElement { + const element = document.createElement("div"); + element.className = "react-flow__node"; + element.dataset.id = id; + vi.spyOn(element, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: rect.width ?? 200, + bottom: rect.height ?? 120, + width: rect.width ?? 200, + height: rect.height ?? 120, + toJSON: () => ({}), + } as DOMRect); + return element; +} + +describe("resolveDroppedConnectionTarget", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("resolves a source-start body drop into a direct connection", () => { + const sourceNode = createNode({ + id: "node-source", + type: "image", + position: { x: 0, y: 0 }, + }); + const targetNode = createNode({ + id: "node-target", + type: "text", + position: { x: 320, y: 200 }, + }); + const targetElement = makeNodeElement("node-target"); + Object.defineProperty(document, "elementsFromPoint", { + value: vi.fn(() => [targetElement]), + configurable: true, + }); + + const result = resolveDroppedConnectionTarget({ + point: { x: 340, y: 220 }, + fromNodeId: "node-source", + fromHandleType: "source", + nodes: [sourceNode, targetNode], + edges: [], + }); + + expect(result).toEqual({ + sourceNodeId: "node-source", + targetNodeId: "node-target", + sourceHandle: undefined, + targetHandle: undefined, + }); + }); + + it("returns null when the pointer is over the canvas background", () => { + const sourceNode = createNode({ + id: "node-source", + type: "image", + position: { x: 0, y: 0 }, + }); + Object.defineProperty(document, "elementsFromPoint", { + value: vi.fn(() => []), + configurable: true, + }); + + const result = resolveDroppedConnectionTarget({ + point: { x: 10, y: 10 }, + fromNodeId: "node-source", + fromHandleType: "source", + nodes: [sourceNode], + edges: [], + }); + + expect(result).toBeNull(); + }); + + it("uses the free compare slot when dropping on a compare node body", () => { + const sourceNode = createNode({ + id: "node-source", + type: "image", + position: { x: 0, y: 0 }, + }); + const compareNode = createNode({ + id: "node-compare", + type: "compare", + position: { x: 320, y: 200 }, + }); + const compareElement = makeNodeElement("node-compare", { + width: 500, + height: 380, + }); + Object.defineProperty(document, "elementsFromPoint", { + value: vi.fn(() => [compareElement]), + configurable: true, + }); + + const result = resolveDroppedConnectionTarget({ + point: { x: 380, y: 290 }, + fromNodeId: "node-source", + fromHandleType: "source", + nodes: [sourceNode, compareNode], + edges: [ + createEdge({ + id: "edge-left", + source: "node-source", + target: "node-compare", + targetHandle: "left", + }), + ], + }); + + expect(result).toEqual({ + sourceNodeId: "node-source", + targetNodeId: "node-compare", + sourceHandle: undefined, + targetHandle: "right", + }); + }); + + it("reverses the connection when the drag starts from a target handle", () => { + const droppedNode = createNode({ + id: "node-dropped", + type: "text", + position: { x: 0, y: 0 }, + }); + const sourceNode = createNode({ + id: "node-source", + type: "image", + position: { x: 320, y: 200 }, + }); + const droppedElement = makeNodeElement("node-dropped"); + Object.defineProperty(document, "elementsFromPoint", { + value: vi.fn(() => [droppedElement]), + configurable: true, + }); + + const result = resolveDroppedConnectionTarget({ + point: { x: 60, y: 60 }, + fromNodeId: "node-source", + fromHandleId: "target-handle", + fromHandleType: "target", + nodes: [droppedNode, sourceNode], + edges: [], + }); + + expect(result).toEqual({ + sourceNodeId: "node-dropped", + targetNodeId: "node-source", + sourceHandle: undefined, + targetHandle: "target-handle", + }); + }); +}); diff --git a/components/canvas/__tests__/use-canvas-connections.test.tsx b/components/canvas/__tests__/use-canvas-connections.test.tsx new file mode 100644 index 0000000..b8ce0b1 --- /dev/null +++ b/components/canvas/__tests__/use-canvas-connections.test.tsx @@ -0,0 +1,316 @@ +// @vitest-environment jsdom + +import React, { act, useEffect, useRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Id } from "@/convex/_generated/dataModel"; + +const mocks = vi.hoisted(() => ({ + resolveDroppedConnectionTarget: vi.fn(), +})); + +vi.mock("@/components/canvas/canvas-helpers", async () => { + const actual = await vi.importActual< + typeof import("@/components/canvas/canvas-helpers") + >("@/components/canvas/canvas-helpers"); + + return { + ...actual, + resolveDroppedConnectionTarget: mocks.resolveDroppedConnectionTarget, + }; +}); + +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"; + +const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; + +const latestHandlersRef: { + current: ReturnType | null; +} = { current: null }; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type HookHarnessProps = { + helperResult: DroppedConnectionTarget | null; + runCreateEdgeMutation?: ReturnType; + showConnectionRejectedToast?: ReturnType; +}; + +function HookHarness({ + helperResult, + runCreateEdgeMutation = vi.fn(async () => undefined), + showConnectionRejectedToast = vi.fn(), +}: HookHarnessProps) { + const [nodes] = useState([ + { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, + { id: "node-target", type: "text", position: { x: 300, y: 200 }, data: {} }, + ]); + const [edges] = useState([]); + const nodesRef = useRef(nodes); + const edgesRef = useRef(edges); + const edgeReconnectSuccessful = useRef(true); + const isReconnectDragActiveRef = useRef(false); + const pendingConnectionCreatesRef = useRef(new Set()); + const resolvedRealIdByClientRequestRef = useRef(new Map>()); + const setEdges = vi.fn(); + const setEdgeSyncNonce = vi.fn(); + + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + + useEffect(() => { + edgesRef.current = edges; + }, [edges]); + + useEffect(() => { + mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult); + }, [helperResult]); + + const handlers = useCanvasConnections({ + canvasId: asCanvasId("canvas-1"), + nodes, + edges, + nodesRef, + edgesRef, + edgeReconnectSuccessful, + isReconnectDragActiveRef, + pendingConnectionCreatesRef, + resolvedRealIdByClientRequestRef, + setEdges, + setEdgeSyncNonce, + screenToFlowPosition: (position) => position, + syncPendingMoveForClientRequest: vi.fn(async () => undefined), + runCreateEdgeMutation, + runRemoveEdgeMutation: vi.fn(async () => undefined), + runCreateNodeWithEdgeFromSourceOnlineOnly: vi.fn(async () => "node-1"), + runCreateNodeWithEdgeToTargetOnlineOnly: vi.fn(async () => "node-1"), + showConnectionRejectedToast, + }); + + useEffect(() => { + latestHandlersRef.current = handlers; + }, [handlers]); + + return null; +} + +describe("useCanvasConnections", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + latestHandlersRef.current = null; + vi.clearAllMocks(); + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + it("creates an edge when a body drop lands on another node", 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( + , + ); + }); + + await act(async () => { + 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: undefined, + }); + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); + }); + + it("opens the node picker when the drop lands on the background", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectEnd( + { clientX: 123, clientY: 456 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 123, y: 456 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(latestHandlersRef.current?.connectionDropMenu).toEqual( + expect.objectContaining({ + screenX: 123, + screenY: 456, + flowX: 123, + flowY: 456, + }), + ); + }); + + it("rejects an invalid body drop without opening the menu", 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( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectEnd( + { clientX: 300, clientY: 210 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 300, y: 210 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); + expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); + }); + + it("reverses the edge direction when the drag starts from a target handle", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectEnd( + { clientX: 200, clientY: 200 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: "target-handle", type: "target" }, + fromPosition: null, + to: { x: 200, y: 200 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-target", + targetNodeId: "node-source", + sourceHandle: undefined, + targetHandle: "target-handle", + }); + }); +}); diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index b19b883..5a10868 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -4,6 +4,7 @@ import { readCanvasOps } from "@/lib/canvas-local-persistence"; import type { Id } from "@/convex/_generated/dataModel"; import type { CanvasNodeDeleteBlockReason } from "@/lib/toast"; import { getSourceImage } from "@/lib/image-pipeline/contracts"; +import { NODE_HANDLE_MAP } from "@/lib/canvas-utils"; export const OPTIMISTIC_NODE_PREFIX = "optimistic_"; export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_"; @@ -67,6 +68,110 @@ export function getConnectEndClientPoint( return null; } +export type DroppedConnectionTarget = { + sourceNodeId: string; + targetNodeId: string; + sourceHandle?: string; + targetHandle?: string; +}; + +function getNodeElementAtClientPoint(point: { x: number; y: number }): HTMLElement | null { + if (typeof document === "undefined") { + return null; + } + + const hit = document.elementsFromPoint(point.x, point.y).find((element) => { + if (!(element instanceof HTMLElement)) return false; + return ( + element.classList.contains("react-flow__node") && + typeof element.dataset.id === "string" && + element.dataset.id.length > 0 + ); + }); + + return hit instanceof HTMLElement ? hit : null; +} + +function getCompareBodyDropTargetHandle(args: { + point: { x: number; y: number }; + nodeElement: HTMLElement; + targetNodeId: string; + edges: RFEdge[]; +}): string | undefined { + const { point, nodeElement, targetNodeId, edges } = args; + const rect = nodeElement.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const incomingEdges = edges.filter( + (edge) => edge.target === targetNodeId && edge.className !== "temp", + ); + const leftTaken = incomingEdges.some((edge) => edge.targetHandle === "left"); + const rightTaken = incomingEdges.some((edge) => edge.targetHandle === "right"); + + if (!leftTaken && !rightTaken) { + return point.y < midY ? "left" : "right"; + } + + if (!leftTaken) { + return "left"; + } + + if (!rightTaken) { + return "right"; + } + + return point.y < midY ? "left" : "right"; +} + +export function resolveDroppedConnectionTarget(args: { + point: { x: number; y: number }; + fromNodeId: string; + fromHandleId?: string; + fromHandleType: "source" | "target"; + nodes: RFNode[]; + edges: RFEdge[]; +}): DroppedConnectionTarget | null { + const nodeElement = getNodeElementAtClientPoint(args.point); + if (!nodeElement) { + return null; + } + + const targetNodeId = nodeElement.dataset.id; + if (!targetNodeId) { + return null; + } + + const targetNode = args.nodes.find((node) => node.id === targetNodeId); + if (!targetNode) { + return null; + } + + const handles = NODE_HANDLE_MAP[targetNode.type ?? ""]; + + if (args.fromHandleType === "source") { + return { + sourceNodeId: args.fromNodeId, + targetNodeId, + sourceHandle: args.fromHandleId, + targetHandle: + targetNode.type === "compare" + ? getCompareBodyDropTargetHandle({ + point: args.point, + nodeElement, + targetNodeId, + edges: args.edges, + }) + : handles?.target, + }; + } + + return { + sourceNodeId: targetNodeId, + targetNodeId: args.fromNodeId, + sourceHandle: handles?.source, + targetHandle: args.fromHandleId, + }; +} + /** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */ export type PendingEdgeSplit = { intersectedEdgeId: Id<"edges">; diff --git a/components/canvas/nodes/color-adjust-node.tsx b/components/canvas/nodes/color-adjust-node.tsx index 2434245..287f191 100644 --- a/components/canvas/nodes/color-adjust-node.tsx +++ b/components/canvas/nodes/color-adjust-node.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { useMutation } from "convex/react"; import { useTranslations } from "next-intl"; @@ -9,10 +9,10 @@ import { Palette } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context"; -import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; +import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data"; import { ParameterSlider, type SliderConfig, @@ -49,42 +49,30 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps const savePreset = useMutation(api.presets.save); const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[]; - const [localData, setLocalData] = useState(() => - normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }), - ); const [presetSelection, setPresetSelection] = useState("custom"); - const localDataRef = useRef(localData); - - useEffect(() => { - localDataRef.current = localData; - }, [localData]); - - useEffect(() => { - const timer = window.setTimeout(() => { - setLocalData( - normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }), - ); - }, 0); - return () => { - window.clearTimeout(timer); - }; - }, [data]); - - const queueSave = useDebouncedCallback(() => { - void queueNodeDataUpdate({ - nodeId: id as Id<"nodes">, - data: localDataRef.current, - }); - }, 16); + const normalizeData = useCallback( + (value: unknown) => + normalizeColorAdjustData({ + ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), + ...(value as Record), + }), + [], + ); + const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ + data, + normalize: normalizeData, + saveDelayMs: 16, + onSave: (next) => + queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: next, + }), + debugLabel: "color-adjust", + }); const updateData = (updater: (draft: ColorAdjustData) => ColorAdjustData) => { setPresetSelection("custom"); - setLocalData((current) => { - const next = updater(current); - localDataRef.current = next; - queueSave(); - return next; - }); + updateLocalData(updater); }; const builtinOptions = useMemo(() => Object.entries(COLOR_PRESETS), []); @@ -165,9 +153,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps if (!preset) return; const next = cloneAdjustmentData(preset); setPresetSelection(value); - setLocalData(next); - localDataRef.current = next; - queueSave(); + applyLocalData(next); return; } if (value.startsWith("user:")) { @@ -176,9 +162,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps if (!preset) return; const next = normalizeColorAdjustData(preset.params); setPresetSelection(value); - setLocalData(next); - localDataRef.current = next; - queueSave(); + applyLocalData(next); } }; diff --git a/components/canvas/nodes/curves-node.tsx b/components/canvas/nodes/curves-node.tsx index 6852ddb..4c6632b 100644 --- a/components/canvas/nodes/curves-node.tsx +++ b/components/canvas/nodes/curves-node.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { useMutation } from "convex/react"; import { useTranslations } from "next-intl"; @@ -9,10 +9,10 @@ import { TrendingUp } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context"; -import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; +import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data"; import { ParameterSlider, type SliderConfig, @@ -49,42 +49,30 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps(() => - normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }), - ); const [presetSelection, setPresetSelection] = useState("custom"); - const localDataRef = useRef(localData); - - useEffect(() => { - localDataRef.current = localData; - }, [localData]); - - useEffect(() => { - const timer = window.setTimeout(() => { - setLocalData( - normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }), - ); - }, 0); - return () => { - window.clearTimeout(timer); - }; - }, [data]); - - const queueSave = useDebouncedCallback(() => { - void queueNodeDataUpdate({ - nodeId: id as Id<"nodes">, - data: localDataRef.current, - }); - }, 16); + const normalizeData = useCallback( + (value: unknown) => + normalizeCurvesData({ + ...cloneAdjustmentData(DEFAULT_CURVES_DATA), + ...(value as Record), + }), + [], + ); + const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ + data, + normalize: normalizeData, + saveDelayMs: 16, + onSave: (next) => + queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: next, + }), + debugLabel: "curves", + }); const updateData = (updater: (draft: CurvesData) => CurvesData) => { setPresetSelection("custom"); - setLocalData((current) => { - const next = updater(current); - localDataRef.current = next; - queueSave(); - return next; - }); + updateLocalData(updater); }; const builtinOptions = useMemo(() => Object.entries(CURVE_PRESETS), []); @@ -136,9 +124,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps(() => - normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }), - ); const [presetSelection, setPresetSelection] = useState("custom"); - const localDataRef = useRef(localData); - - useEffect(() => { - localDataRef.current = localData; - }, [localData]); - - useEffect(() => { - const timer = window.setTimeout(() => { - setLocalData( - normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }), - ); - }, 0); - return () => { - window.clearTimeout(timer); - }; - }, [data]); - - const queueSave = useDebouncedCallback(() => { - void queueNodeDataUpdate({ - nodeId: id as Id<"nodes">, - data: localDataRef.current, - }); - }, 16); + const normalizeData = useCallback( + (value: unknown) => + normalizeDetailAdjustData({ + ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), + ...(value as Record), + }), + [], + ); + const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ + data, + normalize: normalizeData, + saveDelayMs: 16, + onSave: (next) => + queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: next, + }), + debugLabel: "detail-adjust", + }); const updateData = (updater: (draft: DetailAdjustData) => DetailAdjustData) => { setPresetSelection("custom"); - setLocalData((current) => { - const next = updater(current); - localDataRef.current = next; - queueSave(); - return next; - }); + updateLocalData(updater); }; const builtinOptions = useMemo(() => Object.entries(DETAIL_PRESETS), []); @@ -176,9 +164,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp if (!preset) return; const next = cloneAdjustmentData(preset); setPresetSelection(value); - setLocalData(next); - localDataRef.current = next; - queueSave(); + applyLocalData(next); return; } if (value.startsWith("user:")) { @@ -187,9 +173,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp if (!preset) return; const next = normalizeDetailAdjustData(preset.params); setPresetSelection(value); - setLocalData(next); - localDataRef.current = next; - queueSave(); + applyLocalData(next); } }; diff --git a/components/canvas/nodes/light-adjust-node.tsx b/components/canvas/nodes/light-adjust-node.tsx index b1cc7be..bd1f0a5 100644 --- a/components/canvas/nodes/light-adjust-node.tsx +++ b/components/canvas/nodes/light-adjust-node.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { useMutation } from "convex/react"; import { useTranslations } from "next-intl"; @@ -9,10 +9,10 @@ import { Sun } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context"; -import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; +import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data"; import { ParameterSlider, type SliderConfig, @@ -49,42 +49,30 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps const savePreset = useMutation(api.presets.save); const userPresets = useCanvasAdjustmentPresets("light-adjust") as PresetDoc[]; - const [localData, setLocalData] = useState(() => - normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }), - ); const [presetSelection, setPresetSelection] = useState("custom"); - const localDataRef = useRef(localData); - - useEffect(() => { - localDataRef.current = localData; - }, [localData]); - - useEffect(() => { - const timer = window.setTimeout(() => { - setLocalData( - normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }), - ); - }, 0); - return () => { - window.clearTimeout(timer); - }; - }, [data]); - - const queueSave = useDebouncedCallback(() => { - void queueNodeDataUpdate({ - nodeId: id as Id<"nodes">, - data: localDataRef.current, - }); - }, 16); + const normalizeData = useCallback( + (value: unknown) => + normalizeLightAdjustData({ + ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), + ...(value as Record), + }), + [], + ); + const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ + data, + normalize: normalizeData, + saveDelayMs: 16, + onSave: (next) => + queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: next, + }), + debugLabel: "light-adjust", + }); const updateData = (updater: (draft: LightAdjustData) => LightAdjustData) => { setPresetSelection("custom"); - setLocalData((current) => { - const next = updater(current); - localDataRef.current = next; - queueSave(); - return next; - }); + updateLocalData(updater); }; const builtinOptions = useMemo(() => Object.entries(LIGHT_PRESETS), []); @@ -187,9 +175,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps if (!preset) return; const next = cloneAdjustmentData(preset); setPresetSelection(value); - setLocalData(next); - localDataRef.current = next; - queueSave(); + applyLocalData(next); return; } if (value.startsWith("user:")) { @@ -198,9 +184,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps if (!preset) return; const next = normalizeLightAdjustData(preset.params); setPresetSelection(value); - setLocalData(next); - localDataRef.current = next; - queueSave(); + applyLocalData(next); } }; diff --git a/components/canvas/nodes/use-node-local-data.ts b/components/canvas/nodes/use-node-local-data.ts new file mode 100644 index 0000000..0dd0fc6 --- /dev/null +++ b/components/canvas/nodes/use-node-local-data.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; + +function hashNodeData(value: unknown): string { + return JSON.stringify(value); +} + +function logNodeDataDebug(event: string, payload: Record): void { + const nodeSyncDebugEnabled = + process.env.NODE_ENV !== "production" && + (globalThis as typeof globalThis & { __LEMONSPACE_DEBUG_NODE_SYNC__?: boolean }) + .__LEMONSPACE_DEBUG_NODE_SYNC__ === true; + + if (!nodeSyncDebugEnabled) { + return; + } + + console.info("[Canvas node debug]", event, payload); +} + +export function useNodeLocalData({ + data, + normalize, + saveDelayMs, + onSave, + debugLabel, +}: { + data: unknown; + normalize: (value: unknown) => T; + saveDelayMs: number; + onSave: (value: T) => Promise | void; + debugLabel: string; +}) { + const [localData, setLocalDataState] = useState(() => normalize(data)); + const localDataRef = useRef(localData); + const hasPendingLocalChangesRef = useRef(false); + + useEffect(() => { + localDataRef.current = localData; + }, [localData]); + + const queueSave = useDebouncedCallback(() => { + void onSave(localDataRef.current); + }, saveDelayMs); + + useEffect(() => { + const incomingData = normalize(data); + const incomingHash = hashNodeData(incomingData); + const localHash = hashNodeData(localDataRef.current); + + if (incomingHash === localHash) { + hasPendingLocalChangesRef.current = false; + return; + } + + if (hasPendingLocalChangesRef.current) { + logNodeDataDebug("skip-stale-external-data", { + nodeType: debugLabel, + incomingHash, + localHash, + }); + return; + } + + const timer = window.setTimeout(() => { + localDataRef.current = incomingData; + setLocalDataState(incomingData); + }, 0); + + return () => { + window.clearTimeout(timer); + }; + }, [data, debugLabel, normalize]); + + const applyLocalData = useCallback( + (next: T) => { + hasPendingLocalChangesRef.current = true; + localDataRef.current = next; + setLocalDataState(next); + queueSave(); + }, + [queueSave], + ); + + const updateLocalData = useCallback( + (updater: (current: T) => T) => { + hasPendingLocalChangesRef.current = true; + setLocalDataState((current) => { + const next = updater(current); + localDataRef.current = next; + queueSave(); + return next; + }); + }, + [queueSave], + ); + + return { + localData, + applyLocalData, + updateLocalData, + }; +} diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts index 922ab21..2b95710 100644 --- a/components/canvas/use-canvas-connections.ts +++ b/components/canvas/use-canvas-connections.ts @@ -11,6 +11,7 @@ import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import type { CanvasNodeType } from "@/lib/canvas-node-types"; import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers"; +import { resolveDroppedConnectionTarget } from "./canvas-helpers"; import { validateCanvasConnection, validateCanvasConnectionByType, @@ -138,6 +139,41 @@ export function useCanvasConnections({ if (!pt) return; const flow = screenToFlowPosition({ x: pt.x, y: pt.y }); + const droppedConnection = resolveDroppedConnectionTarget({ + point: pt, + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? undefined, + fromHandleType: fromHandle.type, + nodes: nodesRef.current, + edges: edgesRef.current, + }); + + if (droppedConnection) { + const validationError = validateCanvasConnection( + { + source: droppedConnection.sourceNodeId, + target: droppedConnection.targetNodeId, + sourceHandle: droppedConnection.sourceHandle, + targetHandle: droppedConnection.targetHandle, + }, + nodesRef.current, + edgesRef.current, + ); + if (validationError) { + showConnectionRejectedToast(validationError); + return; + } + + void runCreateEdgeMutation({ + canvasId, + sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">, + targetNodeId: droppedConnection.targetNodeId as Id<"nodes">, + sourceHandle: droppedConnection.sourceHandle, + targetHandle: droppedConnection.targetHandle, + }); + return; + } + setConnectionDropMenu({ screenX: pt.x, screenY: pt.y, @@ -148,7 +184,15 @@ export function useCanvasConnections({ fromHandleType: fromHandle.type, }); }, - [isReconnectDragActiveRef, screenToFlowPosition], + [ + canvasId, + edgesRef, + isReconnectDragActiveRef, + nodesRef, + runCreateEdgeMutation, + screenToFlowPosition, + showConnectionRejectedToast, + ], ); const handleConnectionDropPick = useCallback( diff --git a/convex/canvases.ts b/convex/canvases.ts index aa6b217..dcdafc7 100644 --- a/convex/canvases.ts +++ b/convex/canvases.ts @@ -2,6 +2,8 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import { optionalAuth, requireAuth } from "./helpers"; +const PERFORMANCE_LOG_THRESHOLD_MS = 100; + // ============================================================================ // Queries // ============================================================================ @@ -30,14 +32,33 @@ export const list = query({ export const get = query({ args: { canvasId: v.id("canvases") }, handler: async (ctx, { canvasId }) => { + const startedAt = Date.now(); + const authStartedAt = Date.now(); const user = await optionalAuth(ctx); + const authMs = Date.now() - authStartedAt; if (!user) { return null; } + + const canvasLookupStartedAt = Date.now(); const canvas = await ctx.db.get(canvasId); + const canvasLookupMs = Date.now() - canvasLookupStartedAt; if (!canvas || canvas.ownerId !== user.userId) { return null; } + + const durationMs = Date.now() - startedAt; + if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { + console.warn("[canvases.get] slow canvas query", { + canvasId, + userId: user.userId, + authMs, + canvasLookupMs, + canvasUpdatedAt: canvas.updatedAt, + durationMs, + }); + } + return canvas; }, }); diff --git a/convex/edges.ts b/convex/edges.ts index 6413ae2..25fe158 100644 --- a/convex/edges.ts +++ b/convex/edges.ts @@ -90,23 +90,34 @@ export const list = query({ args: { canvasId: v.id("canvases") }, handler: async (ctx, { canvasId }) => { const startedAt = Date.now(); + const authStartedAt = Date.now(); const user = await requireAuth(ctx); + const authMs = Date.now() - authStartedAt; + + const canvasLookupStartedAt = Date.now(); const canvas = await ctx.db.get(canvasId); + const canvasLookupMs = Date.now() - canvasLookupStartedAt; if (!canvas || canvas.ownerId !== user.userId) { return []; } + const collectStartedAt = Date.now(); const edges = await ctx.db .query("edges") .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) .collect(); + const collectMs = Date.now() - collectStartedAt; const durationMs = Date.now() - startedAt; if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { console.warn("[edges.list] slow list query", { canvasId, userId: user.userId, + authMs, + canvasLookupMs, + collectMs, edgeCount: edges.length, + canvasUpdatedAt: canvas.updatedAt, durationMs, }); } @@ -191,6 +202,13 @@ export const create = mutation({ targetHandle: args.targetHandle, }); + console.info("[canvas.updatedAt] touch", { + canvasId: args.canvasId, + source: "edges.create", + edgeId, + sourceNodeId: args.sourceNodeId, + targetNodeId: args.targetNodeId, + }); await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); if (args.clientRequestId) { await ctx.db.insert("mutationRequests", { @@ -239,6 +257,11 @@ export const remove = mutation({ } await ctx.db.delete(edgeId); + console.info("[canvas.updatedAt] touch", { + canvasId: edge.canvasId, + source: "edges.remove", + edgeId, + }); await ctx.db.patch(edge.canvasId, { updatedAt: Date.now() }); console.info("[edges.remove] success", { diff --git a/convex/nodes.ts b/convex/nodes.ts index fc35755..0c1f5ba 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -568,21 +568,32 @@ export const list = query({ args: { canvasId: v.id("canvases") }, handler: async (ctx, { canvasId }) => { const startedAt = Date.now(); + const authStartedAt = Date.now(); const user = await requireAuth(ctx); - await getCanvasOrThrow(ctx, canvasId, user.userId); + const authMs = Date.now() - authStartedAt; + const canvasLookupStartedAt = Date.now(); + const canvas = await getCanvasOrThrow(ctx, canvasId, user.userId); + const canvasLookupMs = Date.now() - canvasLookupStartedAt; + + const collectStartedAt = Date.now(); const nodes = await ctx.db .query("nodes") .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) .collect(); + const collectMs = Date.now() - collectStartedAt; const durationMs = Date.now() - startedAt; if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) { console.warn("[nodes.list] slow list query", { canvasId, userId: user.userId, + authMs, + canvasLookupMs, + collectMs, nodeCount: nodes.length, approxPayloadBytes: estimateSerializedBytes(nodes), + canvasUpdatedAt: canvas.updatedAt, durationMs, }); } @@ -1221,6 +1232,11 @@ export const move = mutation({ await getCanvasOrThrow(ctx, node.canvasId, user.userId); await ctx.db.patch(nodeId, { positionX, positionY }); + console.info("[canvas.updatedAt] touch", { + canvasId: node.canvasId, + source: "nodes.move", + nodeId, + }); await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, }); @@ -1245,6 +1261,12 @@ export const resize = mutation({ ? ADJUSTMENT_MIN_WIDTH : width; await ctx.db.patch(nodeId, { width: clampedWidth, height }); + console.info("[canvas.updatedAt] touch", { + canvasId: node.canvasId, + source: "nodes.resize", + nodeId, + nodeType: node.type, + }); await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, }); @@ -1277,6 +1299,11 @@ export const batchMove = mutation({ await ctx.db.patch(nodeId, { positionX, positionY }); } + console.info("[canvas.updatedAt] touch", { + canvasId, + source: "nodes.batchMove", + moveCount: moves.length, + }); await ctx.db.patch(canvasId, { updatedAt: Date.now() }); }, }); @@ -1297,6 +1324,13 @@ export const updateData = mutation({ await getCanvasOrThrow(ctx, node.canvasId, user.userId); const normalizedData = normalizeNodeDataForWrite(node.type, data); await ctx.db.patch(nodeId, { data: normalizedData }); + console.info("[canvas.updatedAt] touch", { + canvasId: node.canvasId, + source: "nodes.updateData", + nodeId, + nodeType: node.type, + approxDataBytes: estimateSerializedBytes(normalizedData), + }); await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, }); diff --git a/hooks/use-pipeline-preview.ts b/hooks/use-pipeline-preview.ts index ba4378e..b1e9381 100644 --- a/hooks/use-pipeline-preview.ts +++ b/hooks/use-pipeline-preview.ts @@ -37,6 +37,11 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { const [previewAspectRatio, setPreviewAspectRatio] = useState(1); const [error, setError] = useState(null); const runIdRef = useRef(0); + const stableRenderInputRef = useRef<{ + pipelineHash: string; + sourceUrl: string | null; + steps: readonly PipelineStep[]; + } | null>(null); const previewScale = useMemo(() => { if (typeof options.previewScale !== "number" || !Number.isFinite(options.previewScale)) { @@ -65,7 +70,19 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { }, [options.sourceUrl, options.steps]); useEffect(() => { - const sourceUrl = options.sourceUrl; + if (stableRenderInputRef.current?.pipelineHash === pipelineHash) { + return; + } + + stableRenderInputRef.current = { + pipelineHash, + sourceUrl: options.sourceUrl, + steps: options.steps, + }; + }, [pipelineHash, options.sourceUrl, options.steps]); + + useEffect(() => { + const sourceUrl = stableRenderInputRef.current?.sourceUrl ?? null; if (!sourceUrl) { const frameId = window.requestAnimationFrame(() => { setHistogram(emptyHistogram()); @@ -86,7 +103,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { setError(null); void renderPreviewWithWorkerFallback({ sourceUrl, - steps: options.steps, + steps: stableRenderInputRef.current?.steps ?? [], previewWidth, signal: abortController.signal, }) @@ -126,7 +143,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { window.clearTimeout(timer); abortController.abort(); }; - }, [options.sourceUrl, options.steps, pipelineHash, previewWidth]); + }, [pipelineHash, previewWidth]); return { canvasRef, diff --git a/lib/canvas-connection-policy.ts b/lib/canvas-connection-policy.ts index ff98d47..7cdb44b 100644 --- a/lib/canvas-connection-policy.ts +++ b/lib/canvas-connection-policy.ts @@ -30,6 +30,7 @@ export type CanvasConnectionValidationReason = | "unknown-node" | "adjustment-source-invalid" | "adjustment-incoming-limit" + | "compare-incoming-limit" | "adjustment-target-forbidden" | "render-source-invalid"; @@ -49,6 +50,10 @@ export function validateCanvasConnectionPolicy(args: { } } + if (targetType === "compare" && targetIncomingCount >= 2) { + return "compare-incoming-limit"; + } + if (targetType === "render" && !RENDER_ALLOWED_SOURCE_TYPES.has(sourceType)) { return "render-source-invalid"; } @@ -77,6 +82,8 @@ export function getCanvasConnectionValidationMessage( return "Adjustment-Nodes akzeptieren nur Bild-, Asset-, KI-Bild- oder Adjustment-Input."; case "adjustment-incoming-limit": return "Adjustment-Nodes erlauben genau eine eingehende Verbindung."; + case "compare-incoming-limit": + return "Compare-Nodes erlauben genau zwei eingehende Verbindungen."; case "adjustment-target-forbidden": return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden."; case "render-source-invalid": diff --git a/tests/canvas-connection-policy.test.ts b/tests/canvas-connection-policy.test.ts new file mode 100644 index 0000000..44dbb87 --- /dev/null +++ b/tests/canvas-connection-policy.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { + getCanvasConnectionValidationMessage, + validateCanvasConnectionPolicy, +} from "@/lib/canvas-connection-policy"; + +describe("canvas connection policy", () => { + it("limits compare nodes to two incoming connections", () => { + expect( + validateCanvasConnectionPolicy({ + sourceType: "image", + targetType: "compare", + targetIncomingCount: 2, + }), + ).toBe("compare-incoming-limit"); + }); + + it("describes the compare incoming limit", () => { + expect( + getCanvasConnectionValidationMessage("compare-incoming-limit"), + ).toBe("Compare-Nodes erlauben genau zwei eingehende Verbindungen."); + }); +}); diff --git a/tests/light-adjust-node.test.ts b/tests/light-adjust-node.test.ts new file mode 100644 index 0000000..ddfb065 --- /dev/null +++ b/tests/light-adjust-node.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DEFAULT_LIGHT_ADJUST_DATA, type LightAdjustData } from "@/lib/image-pipeline/adjustment-types"; + +type ParameterSliderProps = { + values: Array<{ id: string; value: number }>; + onChange: (values: Array<{ id: string; value: number }>) => void; +}; + +const parameterSliderState = vi.hoisted(() => ({ + latestProps: null as ParameterSliderProps | null, +})); + +vi.mock("@xyflow/react", () => ({ + Handle: () => null, + Position: { Left: "left", Right: "right" }, +})); + +vi.mock("convex/react", () => ({ + useMutation: () => vi.fn(async () => undefined), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("lucide-react", () => ({ + Sun: () => null, +})); + +vi.mock("@/components/canvas/canvas-presets-context", () => ({ + useCanvasAdjustmentPresets: () => [], +})); + +vi.mock("@/components/canvas/canvas-sync-context", () => ({ + useCanvasSync: () => ({ + queueNodeDataUpdate: vi.fn(async () => undefined), + }), +})); + +vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({ + default: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), +})); + +vi.mock("@/components/canvas/nodes/adjustment-preview", () => ({ + default: () => null, +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectItem: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectTrigger: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectValue: () => null, +})); + +vi.mock("@/lib/toast", () => ({ + toast: { + success: vi.fn(), + }, +})); + +vi.mock("@/src/components/tool-ui/parameter-slider", () => ({ + ParameterSlider: (props: ParameterSliderProps) => { + parameterSliderState.latestProps = props; + return null; + }, +})); + +import LightAdjustNode from "@/components/canvas/nodes/light-adjust-node"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("LightAdjustNode", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + vi.useFakeTimers(); + parameterSliderState.latestProps = null; + 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; + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("keeps the locally dragged slider value when stale node data rerenders", async () => { + const staleData: LightAdjustData = { + ...DEFAULT_LIGHT_ADJUST_DATA, + vignette: { + ...DEFAULT_LIGHT_ADJUST_DATA.vignette, + }, + }; + + const renderNode = (data: LightAdjustData) => + root?.render( + createElement(LightAdjustNode, { + id: "light-1", + data, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "light-adjust", + xPos: 0, + yPos: 0, + width: 320, + height: 300, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + } as never), + ); + + await act(async () => { + renderNode({ ...staleData, vignette: { ...staleData.vignette } }); + vi.runOnlyPendingTimers(); + }); + + const sliderPropsBeforeDrag = parameterSliderState.latestProps; + expect(sliderPropsBeforeDrag).not.toBeNull(); + + await act(async () => { + sliderPropsBeforeDrag?.onChange( + sliderPropsBeforeDrag.values.map((entry) => + entry.id === "brightness" ? { ...entry, value: 35 } : entry, + ), + ); + }); + + expect( + parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value, + ).toBe(35); + + await act(async () => { + renderNode({ ...staleData, vignette: { ...staleData.vignette } }); + vi.runOnlyPendingTimers(); + }); + + expect( + parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value, + ).toBe(35); + + await act(async () => { + renderNode({ + ...staleData, + brightness: 35, + vignette: { ...staleData.vignette }, + }); + vi.runOnlyPendingTimers(); + }); + + await act(async () => { + renderNode({ + ...staleData, + brightness: 60, + vignette: { ...staleData.vignette }, + }); + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + }); + + expect( + parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value, + ).toBe(60); + }); +}); diff --git a/tests/use-pipeline-preview.test.ts b/tests/use-pipeline-preview.test.ts new file mode 100644 index 0000000..780e985 --- /dev/null +++ b/tests/use-pipeline-preview.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { emptyHistogram } from "@/lib/image-pipeline/histogram"; +import type { PipelineStep } from "@/lib/image-pipeline/contracts"; + +const workerClientMocks = vi.hoisted(() => ({ + renderPreviewWithWorkerFallback: vi.fn(), +})); + +vi.mock("@/lib/image-pipeline/worker-client", () => ({ + isPipelineAbortError: () => false, + renderPreviewWithWorkerFallback: workerClientMocks.renderPreviewWithWorkerFallback, +})); + +import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; + +function PreviewHarness({ + sourceUrl, + steps, +}: { + sourceUrl: string | null; + steps: PipelineStep[]; +}) { + const { canvasRef } = usePipelinePreview({ + sourceUrl, + steps, + nodeWidth: 320, + }); + + return createElement("canvas", { ref: canvasRef }); +} + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("usePipelinePreview", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + vi.useFakeTimers(); + workerClientMocks.renderPreviewWithWorkerFallback.mockReset(); + workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValue({ + width: 120, + height: 80, + imageData: { data: new Uint8ClampedArray(120 * 80 * 4) }, + histogram: emptyHistogram(), + }); + + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + putImageData: vi.fn(), + } as unknown as CanvasRenderingContext2D); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + vi.useRealTimers(); + }); + + it("does not restart preview rendering when only step references change", async () => { + const stepsA: PipelineStep[] = [ + { + nodeId: "light-1", + type: "light-adjust", + params: { brightness: 10 }, + }, + ]; + + await act(async () => { + root?.render( + createElement(PreviewHarness, { + sourceUrl: "https://cdn.example.com/source.png", + steps: stepsA, + }), + ); + }); + + await act(async () => { + vi.advanceTimersByTime(16); + await Promise.resolve(); + }); + + const stepsB: PipelineStep[] = [ + { + nodeId: "light-1", + type: "light-adjust", + params: { brightness: 10 }, + }, + ]; + + await act(async () => { + root?.render( + createElement(PreviewHarness, { + sourceUrl: "https://cdn.example.com/source.png", + steps: stepsB, + }), + ); + }); + + await act(async () => { + vi.advanceTimersByTime(16); + await Promise.resolve(); + }); + + expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 141a59a..09f08bc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,10 +12,12 @@ export default defineConfig({ include: [ "tests/**/*.test.ts", "components/canvas/__tests__/canvas-helpers.test.ts", + "components/canvas/__tests__/canvas-connection-drop-target.test.tsx", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", "components/canvas/__tests__/compare-node.test.tsx", "components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts", "components/canvas/__tests__/use-canvas-drop.test.tsx", + "components/canvas/__tests__/use-canvas-connections.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",