feat(canvas): accelerate local previews and harden edge flows

This commit is contained in:
2026-04-05 17:28:43 +02:00
parent 451ab0b986
commit de37b63b2b
29 changed files with 2751 additions and 358 deletions

View File

@@ -4,6 +4,7 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { withResolvedCompareData } from "../canvas-helpers";
import {
buildGraphSnapshot,
pruneCanvasGraphNodeDataOverrides,
resolveRenderPreviewInputFromGraph,
} from "@/lib/canvas-render-preview";
@@ -100,6 +101,129 @@ describe("withResolvedCompareData", () => {
});
describe("canvas preview graph helpers", () => {
it("treats node data overrides as complete normalized objects when building a graph snapshot", () => {
const graph = buildGraphSnapshot(
[
{
id: "image-1",
type: "image",
data: {
url: "https://cdn.example.com/persisted.png",
previewUrl: "https://cdn.example.com/persisted-preview.png",
label: "Persisted label",
},
},
],
[],
{
nodeDataOverrides: new Map([
[
"image-1",
{
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/override-preview.png",
},
],
]),
},
);
expect(graph.nodesById.get("image-1")).toMatchObject({
data: {
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/override-preview.png",
},
});
});
it("prunes stale node data overrides for deleted nodes and persisted catch-up", () => {
const overrides = pruneCanvasGraphNodeDataOverrides(
[
{
id: "image-1",
type: "image",
data: {
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/persisted-preview.png",
label: "Persisted label",
},
},
],
new Map([
[
"image-1",
{
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/local-preview.png",
},
],
["deleted-node", { previewUrl: "https://cdn.example.com/stale-preview.png" }],
]),
);
expect(overrides).toEqual(
new Map([
[
"image-1",
{
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/local-preview.png",
},
],
]),
);
});
it("keeps already-pruned node data overrides stable", () => {
const override = { previewUrl: "https://cdn.example.com/local-preview.png" };
const overrides = new Map([["image-1", override]]);
const nextOverrides = pruneCanvasGraphNodeDataOverrides(
[
{
id: "image-1",
type: "image",
data: {
url: "https://cdn.example.com/persisted-source.png",
previewUrl: "https://cdn.example.com/persisted-preview.png",
},
},
],
overrides,
);
expect(nextOverrides).toBe(overrides);
});
it("keeps full nested overrides until persisted data fully catches up", () => {
const override = {
exposure: 0.8,
adjustments: {
shadows: 12,
highlights: -4,
},
};
const nextOverrides = pruneCanvasGraphNodeDataOverrides(
[
{
id: "curves-1",
type: "curves",
data: {
exposure: 0.2,
adjustments: {
shadows: 0,
highlights: -4,
},
},
},
],
new Map([["curves-1", override]]),
);
expect(nextOverrides).toEqual(new Map([["curves-1", override]]));
});
it("resolves the upstream source and pipeline steps from a graph snapshot", () => {
const graph = buildGraphSnapshot(
[
@@ -139,4 +263,50 @@ describe("canvas preview graph helpers", () => {
},
]);
});
it("prefers local node data overrides during render preview resolution", () => {
const graph = buildGraphSnapshot(
[
{
id: "image-1",
type: "image",
data: { url: "https://cdn.example.com/persisted-source.png" },
},
{
id: "curves-1",
type: "curves",
data: { exposure: 0.2 },
},
{
id: "render-1",
type: "render",
data: {},
},
],
[
{ source: "image-1", target: "curves-1" },
{ source: "curves-1", target: "render-1" },
],
{
nodeDataOverrides: new Map([
["image-1", { url: "https://cdn.example.com/override-source.png" }],
["curves-1", { exposure: 0.8 }],
]),
},
);
const preview = resolveRenderPreviewInputFromGraph({
nodeId: "render-1",
graph,
});
expect(preview.sourceUrl).toBe("https://cdn.example.com/override-source.png");
expect(preview.steps).toEqual([
{
nodeId: "curves-1",
type: "curves",
params: { exposure: 0.8 },
},
]);
});
});

View File

@@ -2,6 +2,8 @@ import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
type StoreState = {
nodes: Array<{ id: string; type?: string; data?: unknown }>;
edges: Array<{
@@ -39,6 +41,17 @@ vi.mock("../nodes/compare-surface", () => ({
import CompareNode from "../nodes/compare-node";
function renderCompareNode(props: Record<string, unknown>) {
return renderToStaticMarkup(
<CanvasGraphProvider
nodes={storeState.nodes as Array<{ id: string; type: string; data?: unknown }>}
edges={storeState.edges}
>
<CompareNode {...(props as React.ComponentProps<typeof CompareNode>)} />
</CanvasGraphProvider>,
);
}
describe("CompareNode render preview inputs", () => {
beforeEach(() => {
storeState.nodes = [];
@@ -69,8 +82,7 @@ describe("CompareNode render preview inputs", () => {
},
];
renderToStaticMarkup(
React.createElement(CompareNode, {
renderCompareNode({
id: "compare-1",
data: { leftUrl: "https://cdn.example.com/render-output.png" },
selected: false,
@@ -86,8 +98,7 @@ describe("CompareNode render preview inputs", () => {
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
);
});
expect(compareSurfaceSpy).toHaveBeenCalled();
const previewCall = compareSurfaceSpy.mock.calls.find(
@@ -131,8 +142,7 @@ describe("CompareNode render preview inputs", () => {
},
];
renderToStaticMarkup(
React.createElement(CompareNode, {
renderCompareNode({
id: "compare-1",
data: { leftUrl: "https://cdn.example.com/render-output.png" },
selected: false,
@@ -148,8 +158,7 @@ describe("CompareNode render preview inputs", () => {
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
);
});
expect(compareSurfaceSpy).toHaveBeenCalledTimes(1);
expect(compareSurfaceSpy.mock.calls[0]?.[0]).toMatchObject({

View File

@@ -147,6 +147,14 @@ describe("useCanvasConnections", () => {
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 400, clientY: 260 } as MouseEvent,
{
@@ -192,6 +200,14 @@ describe("useCanvasConnections", () => {
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 123, clientY: 456 } as MouseEvent,
{
@@ -244,6 +260,14 @@ describe("useCanvasConnections", () => {
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 300, clientY: 210 } as MouseEvent,
{
@@ -288,6 +312,14 @@ describe("useCanvasConnections", () => {
});
await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: "target-handle",
handleType: "target",
} as never,
);
latestHandlersRef.current?.onConnectEnd(
{ clientX: 200, clientY: 200 } as MouseEvent,
{
@@ -313,4 +345,50 @@ describe("useCanvasConnections", () => {
targetHandle: "target-handle",
});
});
it("ignores onConnectEnd when no connect drag is active", 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).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).not.toHaveBeenCalled();
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
});
});

View File

@@ -0,0 +1,448 @@
// @vitest-environment jsdom
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CanvasGraphProvider,
useCanvasGraph,
useCanvasGraphPreviewOverrides,
} from "@/components/canvas/canvas-graph-context";
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
type AdjustmentData = {
exposure: number;
label?: string;
};
type HookHarnessProps = {
nodeId: string;
data: AdjustmentData;
onSave: (value: AdjustmentData) => Promise<void> | void;
};
const latestHookRef: {
current:
| {
applyLocalData: (next: AdjustmentData) => void;
updateLocalData: (updater: (current: AdjustmentData) => AdjustmentData) => void;
localData: AdjustmentData;
}
| null;
} = { current: null };
const latestOverridesRef: {
current: ReadonlyMap<string, unknown>;
} = { current: new Map() };
const canvasGraphSetOverrideRef: {
current: ((nodeId: string, data: unknown) => void) | null;
} = { current: null };
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function HookHarness({ nodeId, data, onSave }: HookHarnessProps) {
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<AdjustmentData>({
nodeId,
data,
normalize: (value) => ({ ...(value as AdjustmentData) }),
saveDelayMs: 1000,
onSave,
debugLabel: "curves",
});
useEffect(() => {
latestHookRef.current = {
applyLocalData,
updateLocalData,
localData,
};
return () => {
latestHookRef.current = null;
};
}, [applyLocalData, localData, updateLocalData]);
return null;
}
function GraphProbe() {
const { previewNodeDataOverrides } = useCanvasGraph();
const { setPreviewNodeDataOverride } = useCanvasGraphPreviewOverrides();
useEffect(() => {
latestOverridesRef.current = previewNodeDataOverrides;
canvasGraphSetOverrideRef.current = setPreviewNodeDataOverride;
return () => {
canvasGraphSetOverrideRef.current = null;
};
}, [previewNodeDataOverrides, setPreviewNodeDataOverride]);
return null;
}
function TestApp({
nodeId,
data,
mounted,
onSave,
}: {
nodeId: string;
data: AdjustmentData;
mounted: boolean;
onSave: (value: AdjustmentData) => Promise<void> | void;
}) {
return (
<CanvasGraphProvider nodes={[{ id: nodeId, type: "curves", data }]} edges={[]}>
{mounted ? <HookHarness nodeId={nodeId} data={data} onSave={onSave} /> : null}
<GraphProbe />
</CanvasGraphProvider>
);
}
describe("useNodeLocalData preview overrides", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(async () => {
latestHookRef.current = null;
latestOverridesRef.current = new Map();
canvasGraphSetOverrideRef.current = null;
vi.clearAllMocks();
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
});
it("sets a preview override for local edits and clears it when persisted data catches up", async () => {
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.updateLocalData((current) => ({
...current,
exposure: 0.8,
}));
});
expect(latestOverridesRef.current).toEqual(
new Map([["node-1", { exposure: 0.8, label: "persisted" }]]),
);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.8, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
expect(latestOverridesRef.current).toEqual(new Map());
});
it("does not update the canvas graph provider during render when updating local data", async () => {
const onSave = vi.fn(async () => undefined);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.updateLocalData((current) => ({
...current,
exposure: 0.8,
}));
});
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining(
"Cannot update a component (`CanvasGraphProvider`) while rendering a different component",
),
);
});
it("does not write preview overrides from inside the local state updater", async () => {
const onSave = vi.fn(async () => undefined);
const originalSetPreviewNodeDataOverride = canvasGraphSetOverrideRef.current;
let insideUpdater = false;
canvasGraphSetOverrideRef.current = () => {
if (insideUpdater) {
throw new Error("setPreviewNodeDataOverride called during updater");
}
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await expect(
act(async () => {
latestHookRef.current?.updateLocalData((current) => {
insideUpdater = true;
return {
...current,
exposure: 0.8,
};
});
}),
).resolves.toBeUndefined();
insideUpdater = false;
canvasGraphSetOverrideRef.current = originalSetPreviewNodeDataOverride;
});
it("clears its preview override on unmount", async () => {
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "persisted",
});
});
expect(latestOverridesRef.current).toEqual(
new Map([["node-1", { exposure: 0.8, label: "persisted" }]]),
);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted={false}
onSave={onSave}
/>,
);
});
expect(latestOverridesRef.current).toEqual(new Map());
});
it("cancels a pending debounced save when the node unmounts", async () => {
vi.useFakeTimers();
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "persisted",
});
});
expect(onSave).not.toHaveBeenCalled();
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted={false}
onSave={onSave}
/>,
);
});
expect(onSave).not.toHaveBeenCalled();
expect(latestOverridesRef.current).toEqual(new Map());
vi.useRealTimers();
});
it("clears pending preview state after a rejected save so persisted data can recover", async () => {
vi.useFakeTimers();
const onSave = vi.fn(async () => {
throw new Error("save failed");
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "persisted",
});
});
expect(latestOverridesRef.current).toEqual(
new Map([["node-1", { exposure: 0.8, label: "persisted" }]]),
);
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(latestOverridesRef.current).toEqual(new Map());
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.3, label: "server" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(latestHookRef.current?.localData).toEqual({ exposure: 0.3, label: "server" });
vi.useRealTimers();
});
it("accepts a non-identical persisted update after a successful save", async () => {
vi.useFakeTimers();
const onSave = vi.fn(async () => undefined);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "client",
});
});
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
});
expect(onSave).toHaveBeenCalledTimes(1);
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.75, label: "server-normalized" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(latestHookRef.current?.localData).toEqual({
exposure: 0.75,
label: "server-normalized",
});
expect(latestOverridesRef.current).toEqual(new Map());
vi.useRealTimers();
});
});

View File

@@ -122,22 +122,22 @@ export function useCanvasDeleteHandlers({
nodes,
edges,
);
const edgePromises = bridgeCreates.map((bridgeCreate) =>
runCreateEdgeMutation({
canvasId,
sourceNodeId: bridgeCreate.sourceNodeId,
targetNodeId: bridgeCreate.targetNodeId,
sourceHandle: bridgeCreate.sourceHandle,
targetHandle: bridgeCreate.targetHandle,
}),
);
void Promise.all([
runBatchRemoveNodesMutation({
void (async () => {
await runBatchRemoveNodesMutation({
nodeIds: idsToDelete as Id<"nodes">[],
}),
...edgePromises,
])
});
for (const bridgeCreate of bridgeCreates) {
await runCreateEdgeMutation({
canvasId,
sourceNodeId: bridgeCreate.sourceNodeId,
targetNodeId: bridgeCreate.targetNodeId,
sourceHandle: bridgeCreate.sourceHandle,
targetHandle: bridgeCreate.targetHandle,
});
}
})()
.then(() => {
// Erfolg bedeutet hier nur: Mutation/Queue wurde angenommen.
// Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält.

View File

@@ -1,17 +1,37 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
buildGraphSnapshot,
type CanvasGraphEdgeLike,
type CanvasGraphNodeDataOverrides,
type CanvasGraphNodeLike,
type CanvasGraphSnapshot,
pruneCanvasGraphNodeDataOverrides,
} from "@/lib/canvas-render-preview";
type CanvasGraphContextValue = CanvasGraphSnapshot;
type CanvasGraphContextValue = CanvasGraphSnapshot & {
previewNodeDataOverrides: CanvasGraphNodeDataOverrides;
};
type CanvasGraphPreviewOverridesContextValue = {
setPreviewNodeDataOverride: (nodeId: string, data: unknown) => void;
clearPreviewNodeDataOverride: (nodeId: string) => void;
clearPreviewNodeDataOverrides: () => void;
};
const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
const CanvasGraphPreviewOverridesContext =
createContext<CanvasGraphPreviewOverridesContextValue | null>(null);
export function CanvasGraphProvider({
nodes,
@@ -22,9 +42,88 @@ export function CanvasGraphProvider({
edges: readonly CanvasGraphEdgeLike[];
children: ReactNode;
}) {
const value = useMemo(() => buildGraphSnapshot(nodes, edges), [edges, nodes]);
const [previewNodeDataOverrides, setPreviewNodeDataOverrides] =
useState<CanvasGraphNodeDataOverrides>(() => new Map());
return <CanvasGraphContext.Provider value={value}>{children}</CanvasGraphContext.Provider>;
const setPreviewNodeDataOverride = useCallback((nodeId: string, data: unknown) => {
setPreviewNodeDataOverrides((previous) => {
if (previous.has(nodeId) && Object.is(previous.get(nodeId), data)) {
return previous;
}
const next = new Map(previous);
next.set(nodeId, data);
return next;
});
}, []);
const clearPreviewNodeDataOverride = useCallback((nodeId: string) => {
setPreviewNodeDataOverrides((previous) => {
if (!previous.has(nodeId)) {
return previous;
}
const next = new Map(previous);
next.delete(nodeId);
return next;
});
}, []);
const clearPreviewNodeDataOverrides = useCallback(() => {
setPreviewNodeDataOverrides((previous) => {
if (previous.size === 0) {
return previous;
}
return new Map();
});
}, []);
const prunedPreviewNodeDataOverrides = useMemo(
() => pruneCanvasGraphNodeDataOverrides(nodes, previewNodeDataOverrides),
[nodes, previewNodeDataOverrides],
);
useEffect(() => {
if (prunedPreviewNodeDataOverrides !== previewNodeDataOverrides) {
setPreviewNodeDataOverrides(prunedPreviewNodeDataOverrides);
}
}, [previewNodeDataOverrides, prunedPreviewNodeDataOverrides]);
const graph = useMemo(
() =>
buildGraphSnapshot(nodes, edges, {
nodeDataOverrides: prunedPreviewNodeDataOverrides,
}),
[edges, nodes, prunedPreviewNodeDataOverrides],
);
const value = useMemo(
() => ({
...graph,
previewNodeDataOverrides: prunedPreviewNodeDataOverrides,
}),
[graph, prunedPreviewNodeDataOverrides],
);
const previewOverridesValue = useMemo(
() => ({
setPreviewNodeDataOverride,
clearPreviewNodeDataOverride,
clearPreviewNodeDataOverrides,
}),
[
clearPreviewNodeDataOverride,
clearPreviewNodeDataOverrides,
setPreviewNodeDataOverride,
],
);
return (
<CanvasGraphPreviewOverridesContext.Provider value={previewOverridesValue}>
<CanvasGraphContext.Provider value={value}>{children}</CanvasGraphContext.Provider>
</CanvasGraphPreviewOverridesContext.Provider>
);
}
export function useCanvasGraph(): CanvasGraphContextValue {
@@ -35,3 +134,12 @@ export function useCanvasGraph(): CanvasGraphContextValue {
return context;
}
export function useCanvasGraphPreviewOverrides(): CanvasGraphPreviewOverridesContextValue {
const context = useContext(CanvasGraphPreviewOverridesContext);
if (!context) {
throw new Error("useCanvasGraphPreviewOverrides must be used within CanvasGraphProvider");
}
return context;
}

View File

@@ -308,6 +308,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
closeConnectionDropMenu,
handleConnectionDropPick,
onConnect,
onConnectStart,
onConnectEnd,
onReconnectStart,
onReconnect,
@@ -520,6 +521,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}

View File

@@ -1,46 +1,18 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useMemo } from "react";
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
import {
collectPipelineFromGraph,
getSourceImageFromGraph,
shouldFastPathPreviewPipeline,
} from "@/lib/canvas-render-preview";
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot";
const PREVIEW_PIPELINE_TYPES = new Set([
"curves",
"color-adjust",
"light-adjust",
"detail-adjust",
]);
type PreviewLatencyTrace = {
sequence: number;
changedAtMs: number;
nodeType: string;
origin: string;
};
function readPreviewLatencyTrace(): PreviewLatencyTrace | null {
if (process.env.NODE_ENV === "production") {
return null;
}
const debugGlobals = globalThis as typeof globalThis & {
__LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean;
__LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace;
};
if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) {
return null;
}
return debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ ?? null;
}
const PREVIEW_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]);
export default function AdjustmentPreview({
nodeId,
@@ -54,7 +26,6 @@ export default function AdjustmentPreview({
currentParams: unknown;
}) {
const graph = useCanvasGraph();
const lastLoggedTraceSequenceRef = useRef<number | null>(null);
const sourceUrl = useMemo(
() =>
@@ -93,36 +64,21 @@ export default function AdjustmentPreview({
});
}, [currentParams, currentType, graph, nodeId]);
useEffect(() => {
const trace = readPreviewLatencyTrace();
if (!trace) {
return;
}
const usesFastPreviewDebounce = shouldFastPathPreviewPipeline(
steps,
graph.previewNodeDataOverrides,
);
const shouldDeferHistogram = graph.previewNodeDataOverrides.has(nodeId);
if (lastLoggedTraceSequenceRef.current === trace.sequence) {
return;
}
lastLoggedTraceSequenceRef.current = trace.sequence;
console.info("[Preview latency] downstream-graph-visible", {
nodeId,
nodeType: currentType,
sourceNodeType: trace.nodeType,
sourceOrigin: trace.origin,
sinceChangeMs: performance.now() - trace.changedAtMs,
pipelineDepth: steps.length,
stepTypes: steps.map((step) => step.type),
hasSource: Boolean(sourceUrl),
});
}, [currentType, nodeId, sourceUrl, steps]);
const previewDebounceMs = usesFastPreviewDebounce ? 16 : undefined;
const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } =
usePipelinePreview({
sourceUrl,
steps,
nodeWidth,
includeHistogram: true,
includeHistogram: !shouldDeferHistogram,
debounceMs: previewDebounceMs,
// Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller
// Display-Auflösung rechnen.
previewScale: 0.5,

View File

@@ -59,6 +59,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
nodeId: id,
data,
normalize: normalizeData,
saveDelayMs: 16,

View File

@@ -1,7 +1,11 @@
"use client";
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
import type { RenderPreviewInput } from "@/lib/canvas-render-preview";
import {
shouldFastPathPreviewPipeline,
type RenderPreviewInput,
} from "@/lib/canvas-render-preview";
const EMPTY_STEPS: RenderPreviewInput["steps"] = [];
@@ -22,16 +26,24 @@ export default function CompareSurface({
clipWidthPercent,
preferPreview,
}: CompareSurfaceProps) {
const graph = useCanvasGraph();
const usePreview = Boolean(previewInput && (preferPreview || !finalUrl));
const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null;
const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS;
const visibleFinalUrl = usePreview ? undefined : finalUrl;
const previewDebounceMs = shouldFastPathPreviewPipeline(
previewSteps,
graph.previewNodeDataOverrides,
)
? 16
: undefined;
const { canvasRef, isRendering, error } = usePipelinePreview({
sourceUrl: previewSourceUrl,
steps: previewSteps,
nodeWidth,
includeHistogram: false,
debounceMs: previewDebounceMs,
// Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln
// halten lange Workflows spürbar reaktionsfreudiger.
previewScale: 0.5,

View File

@@ -59,6 +59,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
nodeId: id,
data,
normalize: normalizeData,
saveDelayMs: 16,

View File

@@ -59,6 +59,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
nodeId: id,
data,
normalize: normalizeData,
saveDelayMs: 16,

View File

@@ -59,6 +59,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
nodeId: id,
data,
normalize: normalizeData,
saveDelayMs: 16,

View File

@@ -16,6 +16,7 @@ import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
import {
findSourceNodeFromGraph,
resolveRenderPreviewInputFromGraph,
shouldFastPathPreviewPipeline,
} from "@/lib/canvas-render-preview";
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
import { parseAspectRatioString } from "@/lib/image-formats";
@@ -495,6 +496,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
);
const steps = renderPreviewInput.steps;
const previewDebounceMs = shouldFastPathPreviewPipeline(
steps,
graph.previewNodeDataOverrides,
)
? 16
: undefined;
const renderFingerprint = useMemo(
() => ({
@@ -560,6 +567,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
sourceUrl,
steps,
nodeWidth: previewNodeWidth,
debounceMs: previewDebounceMs,
// Inline-Preview: bewusst kompakt halten, damit Änderungen schneller
// sichtbar werden, besonders in langen Graphen.
previewScale: 0.5,
@@ -577,6 +585,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
steps,
nodeWidth: fullscreenPreviewWidth,
includeHistogram: false,
debounceMs: previewDebounceMs,
previewScale: 0.85,
maxPreviewWidth: 1920,
maxDevicePixelRatio: 1.5,

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCanvasGraphPreviewOverrides } from "@/components/canvas/canvas-graph-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
function hashNodeData(value: unknown): string {
@@ -21,69 +22,67 @@ function logNodeDataDebug(event: string, payload: Record<string, unknown>): void
console.info("[Canvas node debug]", event, payload);
}
type PreviewLatencyTrace = {
sequence: number;
changedAtMs: number;
nodeType: string;
origin: "applyLocalData" | "updateLocalData";
};
function writePreviewLatencyTrace(trace: Omit<PreviewLatencyTrace, "sequence">): void {
if (process.env.NODE_ENV === "production") {
return;
}
const debugGlobals = globalThis as typeof globalThis & {
__LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean;
__LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace;
};
if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) {
return;
}
const nextTrace: PreviewLatencyTrace = {
...trace,
sequence: (debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__?.sequence ?? 0) + 1,
};
debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ = nextTrace;
console.info("[Preview latency] node-local-change", nextTrace);
}
export function useNodeLocalData<T>({
nodeId,
data,
normalize,
saveDelayMs,
onSave,
debugLabel,
}: {
nodeId: string;
data: unknown;
normalize: (value: unknown) => T;
saveDelayMs: number;
onSave: (value: T) => Promise<void> | void;
debugLabel: string;
}) {
const { setPreviewNodeDataOverride, clearPreviewNodeDataOverride } =
useCanvasGraphPreviewOverrides();
const [localData, setLocalDataState] = useState<T>(() => normalize(data));
const localDataRef = useRef(localData);
const persistedDataRef = useRef(localData);
const hasPendingLocalChangesRef = useRef(false);
const localChangeVersionRef = useRef(0);
const isMountedRef = useRef(true);
useEffect(() => {
localDataRef.current = localData;
}, [localData]);
const queueSave = useDebouncedCallback(() => {
void onSave(localDataRef.current);
const savedValue = localDataRef.current;
const savedVersion = localChangeVersionRef.current;
Promise.resolve(onSave(savedValue))
.then(() => {
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
return;
}
hasPendingLocalChangesRef.current = false;
})
.catch(() => {
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
return;
}
hasPendingLocalChangesRef.current = false;
localDataRef.current = persistedDataRef.current;
setLocalDataState(persistedDataRef.current);
clearPreviewNodeDataOverride(nodeId);
});
}, saveDelayMs);
useEffect(() => {
const incomingData = normalize(data);
persistedDataRef.current = incomingData;
const incomingHash = hashNodeData(incomingData);
const localHash = hashNodeData(localDataRef.current);
if (incomingHash === localHash) {
hasPendingLocalChangesRef.current = false;
clearPreviewNodeDataOverride(nodeId);
return;
}
@@ -99,44 +98,46 @@ export function useNodeLocalData<T>({
const timer = window.setTimeout(() => {
localDataRef.current = incomingData;
setLocalDataState(incomingData);
clearPreviewNodeDataOverride(nodeId);
}, 0);
return () => {
window.clearTimeout(timer);
};
}, [data, debugLabel, normalize]);
}, [clearPreviewNodeDataOverride, data, debugLabel, nodeId, normalize]);
useEffect(() => {
return () => {
isMountedRef.current = false;
queueSave.cancel();
clearPreviewNodeDataOverride(nodeId);
};
}, [clearPreviewNodeDataOverride, nodeId, queueSave]);
const applyLocalData = useCallback(
(next: T) => {
localChangeVersionRef.current += 1;
hasPendingLocalChangesRef.current = true;
writePreviewLatencyTrace({
changedAtMs: performance.now(),
nodeType: debugLabel,
origin: "applyLocalData",
});
localDataRef.current = next;
setLocalDataState(next);
setPreviewNodeDataOverride(nodeId, next);
queueSave();
},
[debugLabel, queueSave],
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
);
const updateLocalData = useCallback(
(updater: (current: T) => T) => {
const next = updater(localDataRef.current);
localChangeVersionRef.current += 1;
hasPendingLocalChangesRef.current = true;
setLocalDataState((current) => {
const next = updater(current);
writePreviewLatencyTrace({
changedAtMs: performance.now(),
nodeType: debugLabel,
origin: "updateLocalData",
});
localDataRef.current = next;
queueSave();
return next;
});
localDataRef.current = next;
setLocalDataState(next);
setPreviewNodeDataOverride(nodeId, next);
queueSave();
},
[debugLabel, queueSave],
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
);
return {

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd } from "@xyflow/react";
import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd, OnConnectStart } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel";
import {
@@ -100,14 +100,20 @@ export function useCanvasConnections({
const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
const isConnectDragActiveRef = useRef(false);
const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []);
useEffect(() => {
connectionDropMenuRef.current = connectionDropMenu;
}, [connectionDropMenu]);
const onConnectStart = useCallback<OnConnectStart>(() => {
isConnectDragActiveRef.current = true;
}, []);
const onConnect = useCallback(
(connection: Connection) => {
isConnectDragActiveRef.current = false;
const validationError = validateCanvasConnection(connection, nodes, edges);
if (validationError) {
showConnectionRejectedToast(validationError);
@@ -129,6 +135,11 @@ export function useCanvasConnections({
const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => {
if (!isConnectDragActiveRef.current) {
return;
}
isConnectDragActiveRef.current = false;
if (isReconnectDragActiveRef.current) return;
if (connectionState.isValid === true) return;
const fromNode = connectionState.fromNode;
@@ -153,8 +164,8 @@ export function useCanvasConnections({
{
source: droppedConnection.sourceNodeId,
target: droppedConnection.targetNodeId,
sourceHandle: droppedConnection.sourceHandle,
targetHandle: droppedConnection.targetHandle,
sourceHandle: droppedConnection.sourceHandle ?? null,
targetHandle: droppedConnection.targetHandle ?? null,
},
nodesRef.current,
edgesRef.current,
@@ -333,14 +344,15 @@ export function useCanvasConnections({
},
});
return {
connectionDropMenu,
closeConnectionDropMenu,
handleConnectionDropPick,
onConnect,
onConnectEnd,
onReconnectStart,
onReconnect,
onReconnectEnd,
};
return {
connectionDropMenu,
closeConnectionDropMenu,
handleConnectionDropPick,
onConnect,
onConnectStart,
onConnectEnd,
onReconnectStart,
onReconnect,
onReconnectEnd,
};
}