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:
@@ -67,7 +67,7 @@ export function CanvasCommandPalette() {
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleAddNode = async (
|
||||
const handleAddNode = (
|
||||
type: CanvasNodeTemplate["type"],
|
||||
data: CanvasNodeTemplate["defaultData"],
|
||||
width: number,
|
||||
@@ -75,14 +75,17 @@ export function CanvasCommandPalette() {
|
||||
) => {
|
||||
const offset = (nodeCountRef.current % 8) * 24;
|
||||
nodeCountRef.current += 1;
|
||||
await createNodeWithIntersection({
|
||||
setOpen(false);
|
||||
void createNodeWithIntersection({
|
||||
type,
|
||||
position: { x: 100 + offset, y: 100 + offset },
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
}).catch((error) => {
|
||||
console.error("[CanvasCommandPalette] createNode failed", error);
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -104,7 +107,7 @@ export function CanvasCommandPalette() {
|
||||
key={template.type}
|
||||
keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []}
|
||||
onSelect={() =>
|
||||
void handleAddNode(
|
||||
handleAddNode(
|
||||
template.type,
|
||||
template.defaultData,
|
||||
template.width,
|
||||
|
||||
@@ -28,6 +28,7 @@ type CreateNodeMutation = ReactMutation<
|
||||
data: unknown;
|
||||
parentId?: Id<"nodes">;
|
||||
zIndex?: number;
|
||||
clientRequestId?: string;
|
||||
},
|
||||
Id<"nodes">
|
||||
>
|
||||
@@ -67,6 +68,8 @@ type CreateNodeWithIntersectionInput = {
|
||||
data?: Record<string, unknown>;
|
||||
clientPosition?: FlowPoint;
|
||||
zIndex?: number;
|
||||
/** Correlate optimistic node id with server id after create (see canvas move flush). */
|
||||
clientRequestId?: string;
|
||||
};
|
||||
|
||||
type CanvasPlacementContextValue = {
|
||||
@@ -132,6 +135,10 @@ interface CanvasPlacementProviderProps {
|
||||
canvasId: Id<"canvases">;
|
||||
createNode: CreateNodeMutation;
|
||||
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
|
||||
onCreateNodeSettled?: (payload: {
|
||||
clientRequestId?: string;
|
||||
realId: Id<"nodes">;
|
||||
}) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -139,6 +146,7 @@ export function CanvasPlacementProvider({
|
||||
canvasId,
|
||||
createNode,
|
||||
createNodeWithEdgeSplit,
|
||||
onCreateNodeSettled,
|
||||
children,
|
||||
}: CanvasPlacementProviderProps) {
|
||||
const { flowToScreenPosition } = useReactFlow();
|
||||
@@ -153,6 +161,7 @@ export function CanvasPlacementProvider({
|
||||
data,
|
||||
clientPosition,
|
||||
zIndex,
|
||||
clientRequestId,
|
||||
}: CreateNodeWithIntersectionInput) => {
|
||||
const defaults = NODE_DEFAULTS[type] ?? {
|
||||
width: 200,
|
||||
@@ -174,7 +183,7 @@ export function CanvasPlacementProvider({
|
||||
hitEdgeFromClientPosition ??
|
||||
getIntersectedPersistedEdge(centerClientPosition, edges);
|
||||
|
||||
const nodePayload = {
|
||||
const baseNodePayload = {
|
||||
canvasId,
|
||||
type,
|
||||
positionX: position.x,
|
||||
@@ -189,24 +198,39 @@ export function CanvasPlacementProvider({
|
||||
...(zIndex !== undefined ? { zIndex } : {}),
|
||||
};
|
||||
|
||||
const createNodePayload = {
|
||||
...baseNodePayload,
|
||||
...(clientRequestId !== undefined ? { clientRequestId } : {}),
|
||||
};
|
||||
|
||||
const notifySettled = (realId: Id<"nodes">) => {
|
||||
onCreateNodeSettled?.({ clientRequestId, realId });
|
||||
};
|
||||
|
||||
if (!hitEdge) {
|
||||
return await createNode(nodePayload);
|
||||
const realId = await createNode(createNodePayload);
|
||||
notifySettled(realId);
|
||||
return realId;
|
||||
}
|
||||
|
||||
const handles = NODE_HANDLE_MAP[type];
|
||||
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
|
||||
return await createNode(nodePayload);
|
||||
const realId = await createNode(createNodePayload);
|
||||
notifySettled(realId);
|
||||
return realId;
|
||||
}
|
||||
|
||||
try {
|
||||
return await createNodeWithEdgeSplit({
|
||||
...nodePayload,
|
||||
const realId = await createNodeWithEdgeSplit({
|
||||
...baseNodePayload,
|
||||
splitEdgeId: hitEdge.id as Id<"edges">,
|
||||
newNodeTargetHandle: normalizeHandle(handles.target),
|
||||
newNodeSourceHandle: normalizeHandle(handles.source),
|
||||
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
|
||||
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
|
||||
});
|
||||
notifySettled(realId);
|
||||
return realId;
|
||||
} catch (error) {
|
||||
console.error("[Canvas placement] edge split failed", {
|
||||
edgeId: hitEdge.id,
|
||||
@@ -216,7 +240,14 @@ export function CanvasPlacementProvider({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[canvasId, createNode, createNodeWithEdgeSplit, edges, flowToScreenPosition],
|
||||
[
|
||||
canvasId,
|
||||
createNode,
|
||||
createNodeWithEdgeSplit,
|
||||
edges,
|
||||
flowToScreenPosition,
|
||||
onCreateNodeSettled,
|
||||
],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function CanvasToolbar({
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,18 @@ interface CanvasInnerProps {
|
||||
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[] {
|
||||
const persistedEdges = edges.filter((edge) => edge.className !== "temp");
|
||||
let hasNodeUpdates = false;
|
||||
@@ -353,6 +365,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const moveNode = useMutation(api.nodes.move);
|
||||
const resizeNode = useMutation(api.nodes.resize);
|
||||
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(
|
||||
(localStore, args) => {
|
||||
const current = localStore.getQuery(api.nodes.list, {
|
||||
@@ -360,8 +412,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
});
|
||||
if (current === undefined) return;
|
||||
|
||||
const tempId =
|
||||
`optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"nodes">;
|
||||
const tempId = (
|
||||
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"> = {
|
||||
_id: tempId,
|
||||
@@ -795,13 +850,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
try {
|
||||
// isDragging bleibt true bis alle Mutations resolved sind
|
||||
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({
|
||||
moves: draggedNodes.map((n) => ({
|
||||
moves: realMoves.map((n) => ({
|
||||
nodeId: n.id as Id<"nodes">,
|
||||
positionX: n.position.x,
|
||||
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 {
|
||||
await moveNode({
|
||||
nodeId: node.id as Id<"nodes">,
|
||||
@@ -809,6 +885,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
positionY: node.position.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!intersectedEdgeId) {
|
||||
return;
|
||||
@@ -871,6 +948,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
moveNode,
|
||||
removeEdge,
|
||||
setHighlightedIntersectionEdge,
|
||||
syncPendingMoveForClientRequest,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1002,7 +1080,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
data: {},
|
||||
};
|
||||
|
||||
createNode({
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
void createNode({
|
||||
canvasId,
|
||||
type: nodeType,
|
||||
positionX: position.x,
|
||||
@@ -1010,9 +1089,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
width: defaults.width,
|
||||
height: defaults.height,
|
||||
data: { ...defaults.data, canvasId },
|
||||
clientRequestId,
|
||||
}).then((realId) => {
|
||||
syncPendingMoveForClientRequest(clientRequestId, realId);
|
||||
});
|
||||
},
|
||||
[screenToFlowPosition, createNode, canvasId],
|
||||
[screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest],
|
||||
);
|
||||
|
||||
// ─── Loading State ────────────────────────────────────────────
|
||||
@@ -1032,6 +1114,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
canvasId={canvasId}
|
||||
createNode={createNode}
|
||||
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
|
||||
onCreateNodeSettled={({ clientRequestId, realId }) =>
|
||||
syncPendingMoveForClientRequest(clientRequestId, realId)
|
||||
}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { ReactNode } from "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 { NodeErrorBoundary } from "./node-error-boundary";
|
||||
|
||||
@@ -46,21 +46,16 @@ function NodeToolbarActions() {
|
||||
const nodeId = useNodeId();
|
||||
const { deleteElements, getNode, getNodes, setNodes } = useReactFlow();
|
||||
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!nodeId) return;
|
||||
void deleteElements({ nodes: [{ id: nodeId }] });
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
if (!nodeId || isDuplicating) return;
|
||||
const handleDuplicate = () => {
|
||||
if (!nodeId) return;
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
setIsDuplicating(true);
|
||||
|
||||
try {
|
||||
// Strip internal/runtime fields, keep only user content
|
||||
const originalData = (node.data ?? {}) as Record<string, unknown>;
|
||||
const cleanedData: Record<string, unknown> = {};
|
||||
@@ -81,7 +76,15 @@ function NodeToolbarActions() {
|
||||
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",
|
||||
position: {
|
||||
x: originalPosition.x + 50,
|
||||
@@ -91,34 +94,8 @@ function NodeToolbarActions() {
|
||||
height,
|
||||
data: cleanedData,
|
||||
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) => {
|
||||
@@ -130,17 +107,12 @@ function NodeToolbarActions() {
|
||||
<div className="flex items-center gap-1 rounded-lg border bg-card p-1 shadow-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { stopPropagation(e); void handleDuplicate(); }}
|
||||
onClick={(e) => { stopPropagation(e); handleDuplicate(); }}
|
||||
onPointerDown={stopPropagation}
|
||||
title={isDuplicating ? "Duplicating…" : "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 disabled:opacity-50 disabled:cursor-wait"
|
||||
title="Duplicate"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{isDuplicating ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -229,6 +229,7 @@ export default function PromptNode({
|
||||
outputWidth: viewport.width,
|
||||
outputHeight: viewport.height,
|
||||
},
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
});
|
||||
|
||||
await createEdge({
|
||||
|
||||
@@ -128,11 +128,15 @@ export const create = mutation({
|
||||
data: v.any(),
|
||||
parentId: v.optional(v.id("nodes")),
|
||||
zIndex: v.optional(v.number()),
|
||||
/** Client-only correlation for optimistic UI (not persisted). */
|
||||
clientRequestId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await requireAuth(ctx);
|
||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
||||
|
||||
void args.clientRequestId;
|
||||
|
||||
const nodeId = await ctx.db.insert("nodes", {
|
||||
canvasId: args.canvasId,
|
||||
type: args.type as Doc<"nodes">["type"],
|
||||
|
||||
Reference in New Issue
Block a user