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);
|
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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function CanvasToolbar({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
data,
|
data,
|
||||||
|
clientRequestId: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,19 +850,41 @@ 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) {
|
||||||
await batchMoveNodes({
|
for (const n of draggedNodes) {
|
||||||
moves: draggedNodes.map((n) => ({
|
const cid = clientRequestIdFromOptimisticNodeId(n.id);
|
||||||
nodeId: n.id as Id<"nodes">,
|
if (cid) {
|
||||||
positionX: n.position.x,
|
pendingMoveAfterCreateRef.current.set(cid, {
|
||||||
positionY: n.position.y,
|
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: realMoves.map((n) => ({
|
||||||
|
nodeId: n.id as Id<"nodes">,
|
||||||
|
positionX: n.position.x,
|
||||||
|
positionY: n.position.y,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await moveNode({
|
const cid = clientRequestIdFromOptimisticNodeId(node.id);
|
||||||
nodeId: node.id as Id<"nodes">,
|
if (cid) {
|
||||||
positionX: node.position.x,
|
pendingMoveAfterCreateRef.current.set(cid, {
|
||||||
positionY: node.position.y,
|
positionX: node.position.x,
|
||||||
});
|
positionY: node.position.y,
|
||||||
|
});
|
||||||
|
syncPendingMoveForClientRequest(cid);
|
||||||
|
} else {
|
||||||
|
await moveNode({
|
||||||
|
nodeId: node.id as Id<"nodes">,
|
||||||
|
positionX: node.position.x,
|
||||||
|
positionY: node.position.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!intersectedEdgeId) {
|
if (!intersectedEdgeId) {
|
||||||
@@ -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"} />
|
||||||
|
|||||||
@@ -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 ? (
|
<Copy size={14} />
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Copy size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user