feat(canvas): implement dropped connection resolution and enhance connection handling
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
316
components/canvas/__tests__/use-canvas-connections.test.tsx
Normal file
316
components/canvas/__tests__/use-canvas-connections.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
106
components/canvas/nodes/use-node-local-data.ts
Normal file
106
components/canvas/nodes/use-node-local-data.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
24
tests/canvas-connection-policy.test.ts
Normal file
24
tests/canvas-connection-policy.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
186
tests/light-adjust-node.test.ts
Normal file
186
tests/light-adjust-node.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
122
tests/use-pipeline-preview.test.ts
Normal file
122
tests/use-pipeline-preview.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user