feat(canvas): implement dropped connection resolution and enhance connection handling

This commit is contained in:
2026-04-04 09:56:01 +02:00
parent 12202ad337
commit 90d6fe55b1
18 changed files with 1288 additions and 165 deletions

View File

@@ -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<RFNode> & Pick<RFNode, "id">): RFNode {
return {
id: overrides.id,
position: { x: 0, y: 0 },
data: {},
...overrides,
} as RFNode;
}
function createEdge(
overrides: Partial<RFEdge> & Pick<RFEdge, "id" | "source" | "target">,
): RFEdge {
return {
...overrides,
} as RFEdge;
}
function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): 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",
});
});
});

View File

@@ -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<typeof useCanvasConnections> | null;
} = { current: null };
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type HookHarnessProps = {
helperResult: DroppedConnectionTarget | null;
runCreateEdgeMutation?: ReturnType<typeof vi.fn>;
showConnectionRejectedToast?: ReturnType<typeof vi.fn>;
};
function HookHarness({
helperResult,
runCreateEdgeMutation = vi.fn(async () => undefined),
showConnectionRejectedToast = vi.fn(),
}: HookHarnessProps) {
const [nodes] = useState<RFNode[]>([
{ 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<RFEdge[]>([]);
const nodesRef = useRef(nodes);
const edgesRef = useRef(edges);
const edgeReconnectSuccessful = useRef(true);
const isReconnectDragActiveRef = useRef(false);
const pendingConnectionCreatesRef = useRef(new Set<string>());
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
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(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: undefined,
}}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
/>,
);
});
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(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-source",
sourceHandle: undefined,
targetHandle: undefined,
}}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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(
<HookHarness
helperResult={{
sourceNodeId: "node-target",
targetNodeId: "node-source",
sourceHandle: undefined,
targetHandle: "target-handle",
}}
runCreateEdgeMutation={runCreateEdgeMutation}
/>,
);
});
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",
});
});
});

View File

@@ -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">;

View File

@@ -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<ColorAdjustData>(() =>
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<string, unknown>),
}),
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
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);
}
};

View File

@@ -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<Curv
const savePreset = useMutation(api.presets.save);
const userPresets = useCanvasAdjustmentPresets("curves") as PresetDoc[];
const [localData, setLocalData] = useState<CurvesData>(() =>
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<string, unknown>),
}),
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
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<Curv
const preset = CURVE_PRESETS[key];
if (!preset) return;
setPresetSelection(value);
setLocalData(cloneAdjustmentData(preset));
localDataRef.current = cloneAdjustmentData(preset);
queueSave();
applyLocalData(cloneAdjustmentData(preset));
return;
}
@@ -148,9 +134,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
if (!preset) return;
const next = normalizeCurvesData(preset.params);
setPresetSelection(value);
setLocalData(next);
localDataRef.current = next;
queueSave();
applyLocalData(next);
}
};

View File

@@ -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 { Focus } 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 DetailAdjustNode({ id, data, selected, width }: NodeProp
const savePreset = useMutation(api.presets.save);
const userPresets = useCanvasAdjustmentPresets("detail-adjust") as PresetDoc[];
const [localData, setLocalData] = useState<DetailAdjustData>(() =>
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<string, unknown>),
}),
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
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);
}
};

View File

@@ -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<LightAdjustData>(() =>
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<string, unknown>),
}),
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
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);
}
};

View File

@@ -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<string, unknown>): 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<T>({
data,
normalize,
saveDelayMs,
onSave,
debugLabel,
}: {
data: unknown;
normalize: (value: unknown) => T;
saveDelayMs: number;
onSave: (value: T) => Promise<void> | void;
debugLabel: string;
}) {
const [localData, setLocalDataState] = useState<T>(() => 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,
};
}

View File

@@ -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(

View File

@@ -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;
},
});

View File

@@ -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", {

View File

@@ -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() });
},
});

View File

@@ -37,6 +37,11 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
const [previewAspectRatio, setPreviewAspectRatio] = useState(1);
const [error, setError] = useState<string | null>(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,

View File

@@ -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":

View File

@@ -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.");
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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",