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 { Id } from "@/convex/_generated/dataModel";
|
||||||
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
||||||
import { getSourceImage } from "@/lib/image-pipeline/contracts";
|
import { getSourceImage } from "@/lib/image-pipeline/contracts";
|
||||||
|
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||||
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||||
@@ -67,6 +68,110 @@ export function getConnectEndClientPoint(
|
|||||||
return null;
|
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. */
|
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
|
||||||
export type PendingEdgeSplit = {
|
export type PendingEdgeSplit = {
|
||||||
intersectedEdgeId: Id<"edges">;
|
intersectedEdgeId: Id<"edges">;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -9,10 +9,10 @@ import { Palette } from "lucide-react";
|
|||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||||
|
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||||
import {
|
import {
|
||||||
ParameterSlider,
|
ParameterSlider,
|
||||||
type SliderConfig,
|
type SliderConfig,
|
||||||
@@ -49,42 +49,30 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
const savePreset = useMutation(api.presets.save);
|
const savePreset = useMutation(api.presets.save);
|
||||||
const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[];
|
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 [presetSelection, setPresetSelection] = useState("custom");
|
||||||
const localDataRef = useRef(localData);
|
const normalizeData = useCallback(
|
||||||
|
(value: unknown) =>
|
||||||
useEffect(() => {
|
normalizeColorAdjustData({
|
||||||
localDataRef.current = localData;
|
...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA),
|
||||||
}, [localData]);
|
...(value as Record<string, unknown>),
|
||||||
|
}),
|
||||||
useEffect(() => {
|
[],
|
||||||
const timer = window.setTimeout(() => {
|
);
|
||||||
setLocalData(
|
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
|
||||||
normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }),
|
data,
|
||||||
);
|
normalize: normalizeData,
|
||||||
}, 0);
|
saveDelayMs: 16,
|
||||||
return () => {
|
onSave: (next) =>
|
||||||
window.clearTimeout(timer);
|
queueNodeDataUpdate({
|
||||||
};
|
nodeId: id as Id<"nodes">,
|
||||||
}, [data]);
|
data: next,
|
||||||
|
}),
|
||||||
const queueSave = useDebouncedCallback(() => {
|
debugLabel: "color-adjust",
|
||||||
void queueNodeDataUpdate({
|
});
|
||||||
nodeId: id as Id<"nodes">,
|
|
||||||
data: localDataRef.current,
|
|
||||||
});
|
|
||||||
}, 16);
|
|
||||||
|
|
||||||
const updateData = (updater: (draft: ColorAdjustData) => ColorAdjustData) => {
|
const updateData = (updater: (draft: ColorAdjustData) => ColorAdjustData) => {
|
||||||
setPresetSelection("custom");
|
setPresetSelection("custom");
|
||||||
setLocalData((current) => {
|
updateLocalData(updater);
|
||||||
const next = updater(current);
|
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const builtinOptions = useMemo(() => Object.entries(COLOR_PRESETS), []);
|
const builtinOptions = useMemo(() => Object.entries(COLOR_PRESETS), []);
|
||||||
@@ -165,9 +153,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
const next = cloneAdjustmentData(preset);
|
const next = cloneAdjustmentData(preset);
|
||||||
setPresetSelection(value);
|
setPresetSelection(value);
|
||||||
setLocalData(next);
|
applyLocalData(next);
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value.startsWith("user:")) {
|
if (value.startsWith("user:")) {
|
||||||
@@ -176,9 +162,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
const next = normalizeColorAdjustData(preset.params);
|
const next = normalizeColorAdjustData(preset.params);
|
||||||
setPresetSelection(value);
|
setPresetSelection(value);
|
||||||
setLocalData(next);
|
applyLocalData(next);
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -9,10 +9,10 @@ import { TrendingUp } from "lucide-react";
|
|||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||||
|
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||||
import {
|
import {
|
||||||
ParameterSlider,
|
ParameterSlider,
|
||||||
type SliderConfig,
|
type SliderConfig,
|
||||||
@@ -49,42 +49,30 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
|||||||
const savePreset = useMutation(api.presets.save);
|
const savePreset = useMutation(api.presets.save);
|
||||||
const userPresets = useCanvasAdjustmentPresets("curves") as PresetDoc[];
|
const userPresets = useCanvasAdjustmentPresets("curves") as PresetDoc[];
|
||||||
|
|
||||||
const [localData, setLocalData] = useState<CurvesData>(() =>
|
|
||||||
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),
|
|
||||||
);
|
|
||||||
const [presetSelection, setPresetSelection] = useState("custom");
|
const [presetSelection, setPresetSelection] = useState("custom");
|
||||||
const localDataRef = useRef(localData);
|
const normalizeData = useCallback(
|
||||||
|
(value: unknown) =>
|
||||||
useEffect(() => {
|
normalizeCurvesData({
|
||||||
localDataRef.current = localData;
|
...cloneAdjustmentData(DEFAULT_CURVES_DATA),
|
||||||
}, [localData]);
|
...(value as Record<string, unknown>),
|
||||||
|
}),
|
||||||
useEffect(() => {
|
[],
|
||||||
const timer = window.setTimeout(() => {
|
);
|
||||||
setLocalData(
|
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
|
||||||
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),
|
data,
|
||||||
);
|
normalize: normalizeData,
|
||||||
}, 0);
|
saveDelayMs: 16,
|
||||||
return () => {
|
onSave: (next) =>
|
||||||
window.clearTimeout(timer);
|
queueNodeDataUpdate({
|
||||||
};
|
nodeId: id as Id<"nodes">,
|
||||||
}, [data]);
|
data: next,
|
||||||
|
}),
|
||||||
const queueSave = useDebouncedCallback(() => {
|
debugLabel: "curves",
|
||||||
void queueNodeDataUpdate({
|
});
|
||||||
nodeId: id as Id<"nodes">,
|
|
||||||
data: localDataRef.current,
|
|
||||||
});
|
|
||||||
}, 16);
|
|
||||||
|
|
||||||
const updateData = (updater: (draft: CurvesData) => CurvesData) => {
|
const updateData = (updater: (draft: CurvesData) => CurvesData) => {
|
||||||
setPresetSelection("custom");
|
setPresetSelection("custom");
|
||||||
setLocalData((current) => {
|
updateLocalData(updater);
|
||||||
const next = updater(current);
|
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const builtinOptions = useMemo(() => Object.entries(CURVE_PRESETS), []);
|
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];
|
const preset = CURVE_PRESETS[key];
|
||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
setPresetSelection(value);
|
setPresetSelection(value);
|
||||||
setLocalData(cloneAdjustmentData(preset));
|
applyLocalData(cloneAdjustmentData(preset));
|
||||||
localDataRef.current = cloneAdjustmentData(preset);
|
|
||||||
queueSave();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +134,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
|||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
const next = normalizeCurvesData(preset.params);
|
const next = normalizeCurvesData(preset.params);
|
||||||
setPresetSelection(value);
|
setPresetSelection(value);
|
||||||
setLocalData(next);
|
applyLocalData(next);
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -9,10 +9,10 @@ import { Focus } from "lucide-react";
|
|||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||||
|
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||||
import {
|
import {
|
||||||
ParameterSlider,
|
ParameterSlider,
|
||||||
type SliderConfig,
|
type SliderConfig,
|
||||||
@@ -49,42 +49,30 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
|||||||
const savePreset = useMutation(api.presets.save);
|
const savePreset = useMutation(api.presets.save);
|
||||||
const userPresets = useCanvasAdjustmentPresets("detail-adjust") as PresetDoc[];
|
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 [presetSelection, setPresetSelection] = useState("custom");
|
||||||
const localDataRef = useRef(localData);
|
const normalizeData = useCallback(
|
||||||
|
(value: unknown) =>
|
||||||
useEffect(() => {
|
normalizeDetailAdjustData({
|
||||||
localDataRef.current = localData;
|
...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA),
|
||||||
}, [localData]);
|
...(value as Record<string, unknown>),
|
||||||
|
}),
|
||||||
useEffect(() => {
|
[],
|
||||||
const timer = window.setTimeout(() => {
|
);
|
||||||
setLocalData(
|
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
|
||||||
normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }),
|
data,
|
||||||
);
|
normalize: normalizeData,
|
||||||
}, 0);
|
saveDelayMs: 16,
|
||||||
return () => {
|
onSave: (next) =>
|
||||||
window.clearTimeout(timer);
|
queueNodeDataUpdate({
|
||||||
};
|
nodeId: id as Id<"nodes">,
|
||||||
}, [data]);
|
data: next,
|
||||||
|
}),
|
||||||
const queueSave = useDebouncedCallback(() => {
|
debugLabel: "detail-adjust",
|
||||||
void queueNodeDataUpdate({
|
});
|
||||||
nodeId: id as Id<"nodes">,
|
|
||||||
data: localDataRef.current,
|
|
||||||
});
|
|
||||||
}, 16);
|
|
||||||
|
|
||||||
const updateData = (updater: (draft: DetailAdjustData) => DetailAdjustData) => {
|
const updateData = (updater: (draft: DetailAdjustData) => DetailAdjustData) => {
|
||||||
setPresetSelection("custom");
|
setPresetSelection("custom");
|
||||||
setLocalData((current) => {
|
updateLocalData(updater);
|
||||||
const next = updater(current);
|
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const builtinOptions = useMemo(() => Object.entries(DETAIL_PRESETS), []);
|
const builtinOptions = useMemo(() => Object.entries(DETAIL_PRESETS), []);
|
||||||
@@ -176,9 +164,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
|||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
const next = cloneAdjustmentData(preset);
|
const next = cloneAdjustmentData(preset);
|
||||||
setPresetSelection(value);
|
setPresetSelection(value);
|
||||||
setLocalData(next);
|
applyLocalData(next);
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value.startsWith("user:")) {
|
if (value.startsWith("user:")) {
|
||||||
@@ -187,9 +173,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
|||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
const next = normalizeDetailAdjustData(preset.params);
|
const next = normalizeDetailAdjustData(preset.params);
|
||||||
setPresetSelection(value);
|
setPresetSelection(value);
|
||||||
setLocalData(next);
|
applyLocalData(next);
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -9,10 +9,10 @@ import { Sun } from "lucide-react";
|
|||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||||
|
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||||
import {
|
import {
|
||||||
ParameterSlider,
|
ParameterSlider,
|
||||||
type SliderConfig,
|
type SliderConfig,
|
||||||
@@ -49,42 +49,30 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
const savePreset = useMutation(api.presets.save);
|
const savePreset = useMutation(api.presets.save);
|
||||||
const userPresets = useCanvasAdjustmentPresets("light-adjust") as PresetDoc[];
|
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 [presetSelection, setPresetSelection] = useState("custom");
|
||||||
const localDataRef = useRef(localData);
|
const normalizeData = useCallback(
|
||||||
|
(value: unknown) =>
|
||||||
useEffect(() => {
|
normalizeLightAdjustData({
|
||||||
localDataRef.current = localData;
|
...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA),
|
||||||
}, [localData]);
|
...(value as Record<string, unknown>),
|
||||||
|
}),
|
||||||
useEffect(() => {
|
[],
|
||||||
const timer = window.setTimeout(() => {
|
);
|
||||||
setLocalData(
|
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
|
||||||
normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }),
|
data,
|
||||||
);
|
normalize: normalizeData,
|
||||||
}, 0);
|
saveDelayMs: 16,
|
||||||
return () => {
|
onSave: (next) =>
|
||||||
window.clearTimeout(timer);
|
queueNodeDataUpdate({
|
||||||
};
|
nodeId: id as Id<"nodes">,
|
||||||
}, [data]);
|
data: next,
|
||||||
|
}),
|
||||||
const queueSave = useDebouncedCallback(() => {
|
debugLabel: "light-adjust",
|
||||||
void queueNodeDataUpdate({
|
});
|
||||||
nodeId: id as Id<"nodes">,
|
|
||||||
data: localDataRef.current,
|
|
||||||
});
|
|
||||||
}, 16);
|
|
||||||
|
|
||||||
const updateData = (updater: (draft: LightAdjustData) => LightAdjustData) => {
|
const updateData = (updater: (draft: LightAdjustData) => LightAdjustData) => {
|
||||||
setPresetSelection("custom");
|
setPresetSelection("custom");
|
||||||
setLocalData((current) => {
|
updateLocalData(updater);
|
||||||
const next = updater(current);
|
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const builtinOptions = useMemo(() => Object.entries(LIGHT_PRESETS), []);
|
const builtinOptions = useMemo(() => Object.entries(LIGHT_PRESETS), []);
|
||||||
@@ -187,9 +175,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
const next = cloneAdjustmentData(preset);
|
const next = cloneAdjustmentData(preset);
|
||||||
setPresetSelection(value);
|
setPresetSelection(value);
|
||||||
setLocalData(next);
|
applyLocalData(next);
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value.startsWith("user:")) {
|
if (value.startsWith("user:")) {
|
||||||
@@ -198,9 +184,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
const next = normalizeLightAdjustData(preset.params);
|
const next = normalizeLightAdjustData(preset.params);
|
||||||
setPresetSelection(value);
|
setPresetSelection(value);
|
||||||
setLocalData(next);
|
applyLocalData(next);
|
||||||
localDataRef.current = next;
|
|
||||||
queueSave();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||||
|
|
||||||
import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers";
|
import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers";
|
||||||
|
import { resolveDroppedConnectionTarget } from "./canvas-helpers";
|
||||||
import {
|
import {
|
||||||
validateCanvasConnection,
|
validateCanvasConnection,
|
||||||
validateCanvasConnectionByType,
|
validateCanvasConnectionByType,
|
||||||
@@ -138,6 +139,41 @@ export function useCanvasConnections({
|
|||||||
if (!pt) return;
|
if (!pt) return;
|
||||||
|
|
||||||
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
|
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({
|
setConnectionDropMenu({
|
||||||
screenX: pt.x,
|
screenX: pt.x,
|
||||||
screenY: pt.y,
|
screenY: pt.y,
|
||||||
@@ -148,7 +184,15 @@ export function useCanvasConnections({
|
|||||||
fromHandleType: fromHandle.type,
|
fromHandleType: fromHandle.type,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isReconnectDragActiveRef, screenToFlowPosition],
|
[
|
||||||
|
canvasId,
|
||||||
|
edgesRef,
|
||||||
|
isReconnectDragActiveRef,
|
||||||
|
nodesRef,
|
||||||
|
runCreateEdgeMutation,
|
||||||
|
screenToFlowPosition,
|
||||||
|
showConnectionRejectedToast,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConnectionDropPick = useCallback(
|
const handleConnectionDropPick = useCallback(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { query, mutation } from "./_generated/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { optionalAuth, requireAuth } from "./helpers";
|
import { optionalAuth, requireAuth } from "./helpers";
|
||||||
|
|
||||||
|
const PERFORMANCE_LOG_THRESHOLD_MS = 100;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Queries
|
// Queries
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -30,14 +32,33 @@ export const list = query({
|
|||||||
export const get = query({
|
export const get = query({
|
||||||
args: { canvasId: v.id("canvases") },
|
args: { canvasId: v.id("canvases") },
|
||||||
handler: async (ctx, { canvasId }) => {
|
handler: async (ctx, { canvasId }) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const authStartedAt = Date.now();
|
||||||
const user = await optionalAuth(ctx);
|
const user = await optionalAuth(ctx);
|
||||||
|
const authMs = Date.now() - authStartedAt;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canvasLookupStartedAt = Date.now();
|
||||||
const canvas = await ctx.db.get(canvasId);
|
const canvas = await ctx.db.get(canvasId);
|
||||||
|
const canvasLookupMs = Date.now() - canvasLookupStartedAt;
|
||||||
if (!canvas || canvas.ownerId !== user.userId) {
|
if (!canvas || canvas.ownerId !== user.userId) {
|
||||||
return null;
|
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;
|
return canvas;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,23 +90,34 @@ export const list = query({
|
|||||||
args: { canvasId: v.id("canvases") },
|
args: { canvasId: v.id("canvases") },
|
||||||
handler: async (ctx, { canvasId }) => {
|
handler: async (ctx, { canvasId }) => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const authStartedAt = Date.now();
|
||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
|
const authMs = Date.now() - authStartedAt;
|
||||||
|
|
||||||
|
const canvasLookupStartedAt = Date.now();
|
||||||
const canvas = await ctx.db.get(canvasId);
|
const canvas = await ctx.db.get(canvasId);
|
||||||
|
const canvasLookupMs = Date.now() - canvasLookupStartedAt;
|
||||||
if (!canvas || canvas.ownerId !== user.userId) {
|
if (!canvas || canvas.ownerId !== user.userId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collectStartedAt = Date.now();
|
||||||
const edges = await ctx.db
|
const edges = await ctx.db
|
||||||
.query("edges")
|
.query("edges")
|
||||||
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
||||||
.collect();
|
.collect();
|
||||||
|
const collectMs = Date.now() - collectStartedAt;
|
||||||
|
|
||||||
const durationMs = Date.now() - startedAt;
|
const durationMs = Date.now() - startedAt;
|
||||||
if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
||||||
console.warn("[edges.list] slow list query", {
|
console.warn("[edges.list] slow list query", {
|
||||||
canvasId,
|
canvasId,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
|
authMs,
|
||||||
|
canvasLookupMs,
|
||||||
|
collectMs,
|
||||||
edgeCount: edges.length,
|
edgeCount: edges.length,
|
||||||
|
canvasUpdatedAt: canvas.updatedAt,
|
||||||
durationMs,
|
durationMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -191,6 +202,13 @@ export const create = mutation({
|
|||||||
targetHandle: args.targetHandle,
|
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() });
|
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||||
if (args.clientRequestId) {
|
if (args.clientRequestId) {
|
||||||
await ctx.db.insert("mutationRequests", {
|
await ctx.db.insert("mutationRequests", {
|
||||||
@@ -239,6 +257,11 @@ export const remove = mutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.delete(edgeId);
|
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() });
|
await ctx.db.patch(edge.canvasId, { updatedAt: Date.now() });
|
||||||
|
|
||||||
console.info("[edges.remove] success", {
|
console.info("[edges.remove] success", {
|
||||||
|
|||||||
@@ -568,21 +568,32 @@ export const list = query({
|
|||||||
args: { canvasId: v.id("canvases") },
|
args: { canvasId: v.id("canvases") },
|
||||||
handler: async (ctx, { canvasId }) => {
|
handler: async (ctx, { canvasId }) => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const authStartedAt = Date.now();
|
||||||
const user = await requireAuth(ctx);
|
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
|
const nodes = await ctx.db
|
||||||
.query("nodes")
|
.query("nodes")
|
||||||
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
||||||
.collect();
|
.collect();
|
||||||
|
const collectMs = Date.now() - collectStartedAt;
|
||||||
|
|
||||||
const durationMs = Date.now() - startedAt;
|
const durationMs = Date.now() - startedAt;
|
||||||
if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
||||||
console.warn("[nodes.list] slow list query", {
|
console.warn("[nodes.list] slow list query", {
|
||||||
canvasId,
|
canvasId,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
|
authMs,
|
||||||
|
canvasLookupMs,
|
||||||
|
collectMs,
|
||||||
nodeCount: nodes.length,
|
nodeCount: nodes.length,
|
||||||
approxPayloadBytes: estimateSerializedBytes(nodes),
|
approxPayloadBytes: estimateSerializedBytes(nodes),
|
||||||
|
canvasUpdatedAt: canvas.updatedAt,
|
||||||
durationMs,
|
durationMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1221,6 +1232,11 @@ export const move = mutation({
|
|||||||
|
|
||||||
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
||||||
await ctx.db.patch(nodeId, { positionX, positionY });
|
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() });
|
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1245,6 +1261,12 @@ export const resize = mutation({
|
|||||||
? ADJUSTMENT_MIN_WIDTH
|
? ADJUSTMENT_MIN_WIDTH
|
||||||
: width;
|
: width;
|
||||||
await ctx.db.patch(nodeId, { width: clampedWidth, height });
|
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() });
|
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1277,6 +1299,11 @@ export const batchMove = mutation({
|
|||||||
await ctx.db.patch(nodeId, { positionX, positionY });
|
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() });
|
await ctx.db.patch(canvasId, { updatedAt: Date.now() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1297,6 +1324,13 @@ export const updateData = mutation({
|
|||||||
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
||||||
const normalizedData = normalizeNodeDataForWrite(node.type, data);
|
const normalizedData = normalizeNodeDataForWrite(node.type, data);
|
||||||
await ctx.db.patch(nodeId, { data: normalizedData });
|
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() });
|
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
|||||||
const [previewAspectRatio, setPreviewAspectRatio] = useState(1);
|
const [previewAspectRatio, setPreviewAspectRatio] = useState(1);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const runIdRef = useRef(0);
|
const runIdRef = useRef(0);
|
||||||
|
const stableRenderInputRef = useRef<{
|
||||||
|
pipelineHash: string;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
steps: readonly PipelineStep[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const previewScale = useMemo(() => {
|
const previewScale = useMemo(() => {
|
||||||
if (typeof options.previewScale !== "number" || !Number.isFinite(options.previewScale)) {
|
if (typeof options.previewScale !== "number" || !Number.isFinite(options.previewScale)) {
|
||||||
@@ -65,7 +70,19 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
|||||||
}, [options.sourceUrl, options.steps]);
|
}, [options.sourceUrl, options.steps]);
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (!sourceUrl) {
|
||||||
const frameId = window.requestAnimationFrame(() => {
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
setHistogram(emptyHistogram());
|
setHistogram(emptyHistogram());
|
||||||
@@ -86,7 +103,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
|||||||
setError(null);
|
setError(null);
|
||||||
void renderPreviewWithWorkerFallback({
|
void renderPreviewWithWorkerFallback({
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
steps: options.steps,
|
steps: stableRenderInputRef.current?.steps ?? [],
|
||||||
previewWidth,
|
previewWidth,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
@@ -126,7 +143,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
|||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
}, [options.sourceUrl, options.steps, pipelineHash, previewWidth]);
|
}, [pipelineHash, previewWidth]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canvasRef,
|
canvasRef,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type CanvasConnectionValidationReason =
|
|||||||
| "unknown-node"
|
| "unknown-node"
|
||||||
| "adjustment-source-invalid"
|
| "adjustment-source-invalid"
|
||||||
| "adjustment-incoming-limit"
|
| "adjustment-incoming-limit"
|
||||||
|
| "compare-incoming-limit"
|
||||||
| "adjustment-target-forbidden"
|
| "adjustment-target-forbidden"
|
||||||
| "render-source-invalid";
|
| "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)) {
|
if (targetType === "render" && !RENDER_ALLOWED_SOURCE_TYPES.has(sourceType)) {
|
||||||
return "render-source-invalid";
|
return "render-source-invalid";
|
||||||
}
|
}
|
||||||
@@ -77,6 +82,8 @@ export function getCanvasConnectionValidationMessage(
|
|||||||
return "Adjustment-Nodes akzeptieren nur Bild-, Asset-, KI-Bild- oder Adjustment-Input.";
|
return "Adjustment-Nodes akzeptieren nur Bild-, Asset-, KI-Bild- oder Adjustment-Input.";
|
||||||
case "adjustment-incoming-limit":
|
case "adjustment-incoming-limit":
|
||||||
return "Adjustment-Nodes erlauben genau eine eingehende Verbindung.";
|
return "Adjustment-Nodes erlauben genau eine eingehende Verbindung.";
|
||||||
|
case "compare-incoming-limit":
|
||||||
|
return "Compare-Nodes erlauben genau zwei eingehende Verbindungen.";
|
||||||
case "adjustment-target-forbidden":
|
case "adjustment-target-forbidden":
|
||||||
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
|
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
|
||||||
case "render-source-invalid":
|
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: [
|
include: [
|
||||||
"tests/**/*.test.ts",
|
"tests/**/*.test.ts",
|
||||||
"components/canvas/__tests__/canvas-helpers.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__/canvas-flow-reconciliation-helpers.test.ts",
|
||||||
"components/canvas/__tests__/compare-node.test.tsx",
|
"components/canvas/__tests__/compare-node.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts",
|
"components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts",
|
||||||
"components/canvas/__tests__/use-canvas-drop.test.tsx",
|
"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-node-interactions.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user