feat: enhance canvas components with clientRequestId for optimistic updates

- Added clientRequestId to various canvas components to support optimistic UI updates during node creation and manipulation.
- Updated the canvas command palette, toolbar, and node components to generate unique clientRequestIds for better tracking of user actions.
- Enhanced the canvas placement context to handle clientRequestId for improved correlation between optimistic and real node IDs.
- Refactored node duplication and creation logic to utilize clientRequestId, ensuring smoother user interactions and state management.
This commit is contained in:
Matthias
2026-03-27 23:52:51 +01:00
parent 6e866f2df6
commit 83c0073d51
7 changed files with 169 additions and 72 deletions

View File

@@ -67,7 +67,7 @@ export function CanvasCommandPalette() {
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, []); }, []);
const handleAddNode = async ( const handleAddNode = (
type: CanvasNodeTemplate["type"], type: CanvasNodeTemplate["type"],
data: CanvasNodeTemplate["defaultData"], data: CanvasNodeTemplate["defaultData"],
width: number, width: number,
@@ -75,14 +75,17 @@ export function CanvasCommandPalette() {
) => { ) => {
const offset = (nodeCountRef.current % 8) * 24; const offset = (nodeCountRef.current % 8) * 24;
nodeCountRef.current += 1; nodeCountRef.current += 1;
await createNodeWithIntersection({ setOpen(false);
void createNodeWithIntersection({
type, type,
position: { x: 100 + offset, y: 100 + offset }, position: { x: 100 + offset, y: 100 + offset },
width, width,
height, height,
data, data,
clientRequestId: crypto.randomUUID(),
}).catch((error) => {
console.error("[CanvasCommandPalette] createNode failed", error);
}); });
setOpen(false);
}; };
return ( return (
@@ -104,7 +107,7 @@ export function CanvasCommandPalette() {
key={template.type} key={template.type}
keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []} keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []}
onSelect={() => onSelect={() =>
void handleAddNode( handleAddNode(
template.type, template.type,
template.defaultData, template.defaultData,
template.width, template.width,

View File

@@ -28,6 +28,7 @@ type CreateNodeMutation = ReactMutation<
data: unknown; data: unknown;
parentId?: Id<"nodes">; parentId?: Id<"nodes">;
zIndex?: number; zIndex?: number;
clientRequestId?: string;
}, },
Id<"nodes"> Id<"nodes">
> >
@@ -67,6 +68,8 @@ type CreateNodeWithIntersectionInput = {
data?: Record<string, unknown>; data?: Record<string, unknown>;
clientPosition?: FlowPoint; clientPosition?: FlowPoint;
zIndex?: number; zIndex?: number;
/** Correlate optimistic node id with server id after create (see canvas move flush). */
clientRequestId?: string;
}; };
type CanvasPlacementContextValue = { type CanvasPlacementContextValue = {
@@ -132,6 +135,10 @@ interface CanvasPlacementProviderProps {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
createNode: CreateNodeMutation; createNode: CreateNodeMutation;
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation; createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
onCreateNodeSettled?: (payload: {
clientRequestId?: string;
realId: Id<"nodes">;
}) => void;
children: ReactNode; children: ReactNode;
} }
@@ -139,6 +146,7 @@ export function CanvasPlacementProvider({
canvasId, canvasId,
createNode, createNode,
createNodeWithEdgeSplit, createNodeWithEdgeSplit,
onCreateNodeSettled,
children, children,
}: CanvasPlacementProviderProps) { }: CanvasPlacementProviderProps) {
const { flowToScreenPosition } = useReactFlow(); const { flowToScreenPosition } = useReactFlow();
@@ -153,6 +161,7 @@ export function CanvasPlacementProvider({
data, data,
clientPosition, clientPosition,
zIndex, zIndex,
clientRequestId,
}: CreateNodeWithIntersectionInput) => { }: CreateNodeWithIntersectionInput) => {
const defaults = NODE_DEFAULTS[type] ?? { const defaults = NODE_DEFAULTS[type] ?? {
width: 200, width: 200,
@@ -174,7 +183,7 @@ export function CanvasPlacementProvider({
hitEdgeFromClientPosition ?? hitEdgeFromClientPosition ??
getIntersectedPersistedEdge(centerClientPosition, edges); getIntersectedPersistedEdge(centerClientPosition, edges);
const nodePayload = { const baseNodePayload = {
canvasId, canvasId,
type, type,
positionX: position.x, positionX: position.x,
@@ -189,24 +198,39 @@ export function CanvasPlacementProvider({
...(zIndex !== undefined ? { zIndex } : {}), ...(zIndex !== undefined ? { zIndex } : {}),
}; };
const createNodePayload = {
...baseNodePayload,
...(clientRequestId !== undefined ? { clientRequestId } : {}),
};
const notifySettled = (realId: Id<"nodes">) => {
onCreateNodeSettled?.({ clientRequestId, realId });
};
if (!hitEdge) { if (!hitEdge) {
return await createNode(nodePayload); const realId = await createNode(createNodePayload);
notifySettled(realId);
return realId;
} }
const handles = NODE_HANDLE_MAP[type]; const handles = NODE_HANDLE_MAP[type];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) { if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
return await createNode(nodePayload); const realId = await createNode(createNodePayload);
notifySettled(realId);
return realId;
} }
try { try {
return await createNodeWithEdgeSplit({ const realId = await createNodeWithEdgeSplit({
...nodePayload, ...baseNodePayload,
splitEdgeId: hitEdge.id as Id<"edges">, splitEdgeId: hitEdge.id as Id<"edges">,
newNodeTargetHandle: normalizeHandle(handles.target), newNodeTargetHandle: normalizeHandle(handles.target),
newNodeSourceHandle: normalizeHandle(handles.source), newNodeSourceHandle: normalizeHandle(handles.source),
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle), splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
splitTargetHandle: normalizeHandle(hitEdge.targetHandle), splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
}); });
notifySettled(realId);
return realId;
} catch (error) { } catch (error) {
console.error("[Canvas placement] edge split failed", { console.error("[Canvas placement] edge split failed", {
edgeId: hitEdge.id, edgeId: hitEdge.id,
@@ -216,7 +240,14 @@ export function CanvasPlacementProvider({
throw error; throw error;
} }
}, },
[canvasId, createNode, createNodeWithEdgeSplit, edges, flowToScreenPosition], [
canvasId,
createNode,
createNodeWithEdgeSplit,
edges,
flowToScreenPosition,
onCreateNodeSettled,
],
); );
const value = useMemo( const value = useMemo(

View File

@@ -34,6 +34,7 @@ export default function CanvasToolbar({
width, width,
height, height,
data, data,
clientRequestId: crypto.randomUUID(),
}); });
}; };

View File

@@ -46,6 +46,18 @@ interface CanvasInnerProps {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
} }
const OPTIMISTIC_NODE_PREFIX = "optimistic_";
function isOptimisticNodeId(id: string): boolean {
return id.startsWith(OPTIMISTIC_NODE_PREFIX);
}
function clientRequestIdFromOptimisticNodeId(id: string): string | null {
if (!isOptimisticNodeId(id)) return null;
const suffix = id.slice(OPTIMISTIC_NODE_PREFIX.length);
return suffix.length > 0 ? suffix : null;
}
function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] { function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] {
const persistedEdges = edges.filter((edge) => edge.className !== "temp"); const persistedEdges = edges.filter((edge) => edge.className !== "temp");
let hasNodeUpdates = false; let hasNodeUpdates = false;
@@ -353,6 +365,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const moveNode = useMutation(api.nodes.move); const moveNode = useMutation(api.nodes.move);
const resizeNode = useMutation(api.nodes.resize); const resizeNode = useMutation(api.nodes.resize);
const batchMoveNodes = useMutation(api.nodes.batchMove); const batchMoveNodes = useMutation(api.nodes.batchMove);
const pendingMoveAfterCreateRef = useRef(
new Map<string, { positionX: number; positionY: number }>(),
);
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
/** Pairing: create kann vor oder nach Drag-Ende fertig sein — was zuerst kommt, speichert; das andere triggert moveNode. */
const syncPendingMoveForClientRequest = useCallback(
(clientRequestId: string | undefined, realId?: Id<"nodes">) => {
if (!clientRequestId) return;
if (realId !== undefined) {
const pending = pendingMoveAfterCreateRef.current.get(clientRequestId);
if (pending) {
pendingMoveAfterCreateRef.current.delete(clientRequestId);
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
void moveNode({
nodeId: realId,
positionX: pending.positionX,
positionY: pending.positionY,
});
return;
}
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
return;
}
const r = resolvedRealIdByClientRequestRef.current.get(clientRequestId);
const p = pendingMoveAfterCreateRef.current.get(clientRequestId);
if (!r || !p) return;
pendingMoveAfterCreateRef.current.delete(clientRequestId);
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
void moveNode({
nodeId: r,
positionX: p.positionX,
positionY: p.positionY,
});
},
[moveNode],
);
const createNode = useMutation(api.nodes.create).withOptimisticUpdate( const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
(localStore, args) => { (localStore, args) => {
const current = localStore.getQuery(api.nodes.list, { const current = localStore.getQuery(api.nodes.list, {
@@ -360,8 +412,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}); });
if (current === undefined) return; if (current === undefined) return;
const tempId = const tempId = (
`optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"nodes">; args.clientRequestId
? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"nodes">;
const synthetic: Doc<"nodes"> = { const synthetic: Doc<"nodes"> = {
_id: tempId, _id: tempId,
@@ -795,13 +850,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
try { try {
// isDragging bleibt true bis alle Mutations resolved sind // isDragging bleibt true bis alle Mutations resolved sind
if (draggedNodes.length > 1) { if (draggedNodes.length > 1) {
for (const n of draggedNodes) {
const cid = clientRequestIdFromOptimisticNodeId(n.id);
if (cid) {
pendingMoveAfterCreateRef.current.set(cid, {
positionX: n.position.x,
positionY: n.position.y,
});
syncPendingMoveForClientRequest(cid);
}
}
const realMoves = draggedNodes.filter((n) => !isOptimisticNodeId(n.id));
if (realMoves.length > 0) {
await batchMoveNodes({ await batchMoveNodes({
moves: draggedNodes.map((n) => ({ moves: realMoves.map((n) => ({
nodeId: n.id as Id<"nodes">, nodeId: n.id as Id<"nodes">,
positionX: n.position.x, positionX: n.position.x,
positionY: n.position.y, positionY: n.position.y,
})), })),
}); });
}
} else {
const cid = clientRequestIdFromOptimisticNodeId(node.id);
if (cid) {
pendingMoveAfterCreateRef.current.set(cid, {
positionX: node.position.x,
positionY: node.position.y,
});
syncPendingMoveForClientRequest(cid);
} else { } else {
await moveNode({ await moveNode({
nodeId: node.id as Id<"nodes">, nodeId: node.id as Id<"nodes">,
@@ -809,6 +885,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
positionY: node.position.y, positionY: node.position.y,
}); });
} }
}
if (!intersectedEdgeId) { if (!intersectedEdgeId) {
return; return;
@@ -871,6 +948,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
moveNode, moveNode,
removeEdge, removeEdge,
setHighlightedIntersectionEdge, setHighlightedIntersectionEdge,
syncPendingMoveForClientRequest,
], ],
); );
@@ -1002,7 +1080,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
data: {}, data: {},
}; };
createNode({ const clientRequestId = crypto.randomUUID();
void createNode({
canvasId, canvasId,
type: nodeType, type: nodeType,
positionX: position.x, positionX: position.x,
@@ -1010,9 +1089,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
width: defaults.width, width: defaults.width,
height: defaults.height, height: defaults.height,
data: { ...defaults.data, canvasId }, data: { ...defaults.data, canvasId },
clientRequestId,
}).then((realId) => {
syncPendingMoveForClientRequest(clientRequestId, realId);
}); });
}, },
[screenToFlowPosition, createNode, canvasId], [screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest],
); );
// ─── Loading State ──────────────────────────────────────────── // ─── Loading State ────────────────────────────────────────────
@@ -1032,6 +1114,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
canvasId={canvasId} canvasId={canvasId}
createNode={createNode} createNode={createNode}
createNodeWithEdgeSplit={createNodeWithEdgeSplit} createNodeWithEdgeSplit={createNodeWithEdgeSplit}
onCreateNodeSettled={({ clientRequestId, realId }) =>
syncPendingMoveForClientRequest(clientRequestId, realId)
}
> >
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} /> <CanvasToolbar canvasName={canvas?.name ?? "canvas"} />

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, type ReactNode } from "react"; import type { ReactNode } from "react";
import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react"; import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react";
import { Trash2, Copy, Loader2 } from "lucide-react"; import { Trash2, Copy } from "lucide-react";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { NodeErrorBoundary } from "./node-error-boundary"; import { NodeErrorBoundary } from "./node-error-boundary";
@@ -46,21 +46,16 @@ function NodeToolbarActions() {
const nodeId = useNodeId(); const nodeId = useNodeId();
const { deleteElements, getNode, getNodes, setNodes } = useReactFlow(); const { deleteElements, getNode, getNodes, setNodes } = useReactFlow();
const { createNodeWithIntersection } = useCanvasPlacement(); const { createNodeWithIntersection } = useCanvasPlacement();
const [isDuplicating, setIsDuplicating] = useState(false);
const handleDelete = () => { const handleDelete = () => {
if (!nodeId) return; if (!nodeId) return;
void deleteElements({ nodes: [{ id: nodeId }] }); void deleteElements({ nodes: [{ id: nodeId }] });
}; };
const handleDuplicate = async () => { const handleDuplicate = () => {
if (!nodeId || isDuplicating) return; if (!nodeId) return;
const node = getNode(nodeId); const node = getNode(nodeId);
if (!node) return; if (!node) return;
setIsDuplicating(true);
try {
// Strip internal/runtime fields, keep only user content // Strip internal/runtime fields, keep only user content
const originalData = (node.data ?? {}) as Record<string, unknown>; const originalData = (node.data ?? {}) as Record<string, unknown>;
const cleanedData: Record<string, unknown> = {}; const cleanedData: Record<string, unknown> = {};
@@ -81,7 +76,15 @@ function NodeToolbarActions() {
0, 0,
); );
const createdNodeId = await createNodeWithIntersection({ // Deselect source node immediately for instant visual feedback
setNodes((nodes) =>
nodes.map((n) =>
n.id === nodeId ? { ...n, selected: false } : n,
),
);
// Fire-and-forget: optimistic update makes the duplicate appear instantly
void createNodeWithIntersection({
type: node.type ?? "text", type: node.type ?? "text",
position: { position: {
x: originalPosition.x + 50, x: originalPosition.x + 50,
@@ -91,34 +94,8 @@ function NodeToolbarActions() {
height, height,
data: cleanedData, data: cleanedData,
zIndex: maxZIndex + 1, zIndex: maxZIndex + 1,
clientRequestId: crypto.randomUUID(),
}); });
const selectCreatedNode = (attempt = 0) => {
const createdNode = getNode(createdNodeId);
if (!createdNode) {
if (attempt < 10) {
requestAnimationFrame(() => selectCreatedNode(attempt + 1));
}
return;
}
setNodes((nodes) =>
nodes.map((n) => {
if (n.id === nodeId) {
return { ...n, selected: false };
}
if (n.id === createdNodeId) {
return { ...n, selected: true };
}
return n;
}),
);
};
selectCreatedNode();
} finally {
setIsDuplicating(false);
}
}; };
const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => { const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => {
@@ -130,17 +107,12 @@ function NodeToolbarActions() {
<div className="flex items-center gap-1 rounded-lg border bg-card p-1 shadow-md"> <div className="flex items-center gap-1 rounded-lg border bg-card p-1 shadow-md">
<button <button
type="button" type="button"
onClick={(e) => { stopPropagation(e); void handleDuplicate(); }} onClick={(e) => { stopPropagation(e); handleDuplicate(); }}
onPointerDown={stopPropagation} onPointerDown={stopPropagation}
title={isDuplicating ? "Duplicating…" : "Duplicate"} title="Duplicate"
disabled={isDuplicating} className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:cursor-wait"
> >
{isDuplicating ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Copy size={14} /> <Copy size={14} />
)}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -229,6 +229,7 @@ export default function PromptNode({
outputWidth: viewport.width, outputWidth: viewport.width,
outputHeight: viewport.height, outputHeight: viewport.height,
}, },
clientRequestId: crypto.randomUUID(),
}); });
await createEdge({ await createEdge({

View File

@@ -128,11 +128,15 @@ export const create = mutation({
data: v.any(), data: v.any(),
parentId: v.optional(v.id("nodes")), parentId: v.optional(v.id("nodes")),
zIndex: v.optional(v.number()), zIndex: v.optional(v.number()),
/** Client-only correlation for optimistic UI (not persisted). */
clientRequestId: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, args.canvasId, user.userId); await getCanvasOrThrow(ctx, args.canvasId, user.userId);
void args.clientRequestId;
const nodeId = await ctx.db.insert("nodes", { const nodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId, canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"], type: args.type as Doc<"nodes">["type"],