Enable offline canvas create sync with optimistic ID remapping
This commit is contained in:
@@ -34,6 +34,7 @@ import { msg } from "@/lib/toast-messages";
|
||||
import {
|
||||
enqueueCanvasOp,
|
||||
readCanvasSnapshot,
|
||||
remapCanvasOpNodeId,
|
||||
resolveCanvasOp,
|
||||
resolveCanvasOps,
|
||||
writeCanvasSnapshot,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
enqueueCanvasSyncOp,
|
||||
listCanvasSyncOps,
|
||||
markCanvasSyncOpFailed,
|
||||
remapCanvasSyncNodeId,
|
||||
} from "@/lib/canvas-op-queue";
|
||||
|
||||
import {
|
||||
@@ -229,6 +231,21 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
);
|
||||
/** Vorheriger Stand von api.nodes.list-IDs — um genau die neu eingetretene Node-ID vor Mutation-.then zu erkennen. */
|
||||
const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set<string>());
|
||||
const syncPendingMoveForClientRequestRef = useRef<
|
||||
(clientRequestId: string | undefined, realId?: Id<"nodes">) => Promise<void>
|
||||
>(async () => {});
|
||||
const enqueueSyncMutationRef = useRef<
|
||||
<TType extends keyof CanvasSyncOpPayloadByType>(
|
||||
type: TType,
|
||||
payload: CanvasSyncOpPayloadByType[TType],
|
||||
) => Promise<void>
|
||||
>(async () => {});
|
||||
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [edgeSyncNonce, setEdgeSyncNonce] = useState(0);
|
||||
/** Convex-Merge: Position nicht mit veraltetem Snapshot überschreiben (RF-`dragging` kommt oft verzögert). */
|
||||
const preferLocalPositionNodeIdsRef = useRef(new Set<string>());
|
||||
|
||||
const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
|
||||
(localStore, args) => {
|
||||
@@ -419,7 +436,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
});
|
||||
if (edgeList === undefined) return;
|
||||
|
||||
const tempId = `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"edges">;
|
||||
const tempId = (
|
||||
args.clientRequestId
|
||||
? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`
|
||||
: `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
|
||||
) as Id<"edges">;
|
||||
const synthetic: Doc<"edges"> = {
|
||||
_id: tempId,
|
||||
_creationTime: Date.now(),
|
||||
@@ -436,6 +457,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
);
|
||||
},
|
||||
);
|
||||
const createNodeRaw = useMutation(api.nodes.create);
|
||||
const createNodeWithEdgeFromSourceRaw = useMutation(
|
||||
api.nodes.createWithEdgeFromSource,
|
||||
);
|
||||
const createNodeWithEdgeToTargetRaw = useMutation(
|
||||
api.nodes.createWithEdgeToTarget,
|
||||
);
|
||||
const createEdgeRaw = useMutation(api.edges.create);
|
||||
|
||||
const removeEdge = useMutation(api.edges.remove).withOptimisticUpdate(
|
||||
(localStore, args) => {
|
||||
@@ -449,6 +478,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
||||
@@ -477,41 +508,226 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
lastOfflineUnsupportedToastAtRef.current = now;
|
||||
toast.warning(
|
||||
"Offline aktuell nicht unterstützt",
|
||||
`${label} ist in Stufe 1 nur online verfügbar.`,
|
||||
`${label} ist aktuell nur online verfügbar.`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const addOptimisticNodeLocally = useCallback((
|
||||
args: Parameters<typeof createNode>[0] & { clientRequestId: string },
|
||||
): Id<"nodes"> => {
|
||||
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`;
|
||||
setNodes((current) => {
|
||||
if (current.some((node) => node.id === optimisticNodeId)) {
|
||||
return current;
|
||||
}
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: optimisticNodeId,
|
||||
type: args.type,
|
||||
position: { x: args.positionX, y: args.positionY },
|
||||
data: args.data,
|
||||
style: { width: args.width, height: args.height },
|
||||
parentId: args.parentId as string | undefined,
|
||||
zIndex: args.zIndex,
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
return optimisticNodeId as Id<"nodes">;
|
||||
}, []);
|
||||
|
||||
const addOptimisticEdgeLocally = useCallback((args: {
|
||||
clientRequestId: string;
|
||||
sourceNodeId: Id<"nodes">;
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
}): Id<"edges"> => {
|
||||
const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`;
|
||||
setEdges((current) => {
|
||||
if (current.some((edge) => edge.id === optimisticEdgeId)) {
|
||||
return current;
|
||||
}
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: optimisticEdgeId,
|
||||
source: args.sourceNodeId as string,
|
||||
target: args.targetNodeId as string,
|
||||
sourceHandle: args.sourceHandle,
|
||||
targetHandle: args.targetHandle,
|
||||
},
|
||||
];
|
||||
});
|
||||
return optimisticEdgeId as Id<"edges">;
|
||||
}, []);
|
||||
|
||||
const removeOptimisticCreateLocally = useCallback((args: {
|
||||
clientRequestId: string;
|
||||
removeNode?: boolean;
|
||||
removeEdge?: boolean;
|
||||
}): void => {
|
||||
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`;
|
||||
const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`;
|
||||
|
||||
if (args.removeNode) {
|
||||
setNodes((current) =>
|
||||
current.filter((node) => node.id !== optimisticNodeId),
|
||||
);
|
||||
setEdges((current) =>
|
||||
current.filter(
|
||||
(edge) =>
|
||||
edge.source !== optimisticNodeId && edge.target !== optimisticNodeId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (args.removeEdge) {
|
||||
setEdges((current) =>
|
||||
current.filter((edge) => edge.id !== optimisticEdgeId),
|
||||
);
|
||||
}
|
||||
|
||||
pendingMoveAfterCreateRef.current.delete(args.clientRequestId);
|
||||
pendingEdgeSplitByClientRequestRef.current.delete(args.clientRequestId);
|
||||
pendingConnectionCreatesRef.current.delete(args.clientRequestId);
|
||||
resolvedRealIdByClientRequestRef.current.delete(args.clientRequestId);
|
||||
}, []);
|
||||
|
||||
const remapOptimisticNodeLocally = useCallback(async (
|
||||
clientRequestId: string,
|
||||
realId: Id<"nodes">,
|
||||
): Promise<void> => {
|
||||
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
||||
const realNodeId = realId as string;
|
||||
|
||||
setNodes((current) =>
|
||||
current.map((node) => {
|
||||
const nextParentId =
|
||||
node.parentId === optimisticNodeId ? realNodeId : node.parentId;
|
||||
if (node.id !== optimisticNodeId && nextParentId === node.parentId) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
id: node.id === optimisticNodeId ? realNodeId : node.id,
|
||||
parentId: nextParentId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setEdges((current) =>
|
||||
current.map((edge) => {
|
||||
const nextSource =
|
||||
edge.source === optimisticNodeId ? realNodeId : edge.source;
|
||||
const nextTarget =
|
||||
edge.target === optimisticNodeId ? realNodeId : edge.target;
|
||||
if (nextSource === edge.source && nextTarget === edge.target) {
|
||||
return edge;
|
||||
}
|
||||
return {
|
||||
...edge,
|
||||
source: nextSource,
|
||||
target: nextTarget,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setAssetBrowserTargetNodeId((current) =>
|
||||
current === optimisticNodeId ? realNodeId : current,
|
||||
);
|
||||
|
||||
const pinnedPos =
|
||||
pendingLocalPositionUntilConvexMatchesRef.current.get(optimisticNodeId);
|
||||
if (pinnedPos) {
|
||||
pendingLocalPositionUntilConvexMatchesRef.current.delete(optimisticNodeId);
|
||||
pendingLocalPositionUntilConvexMatchesRef.current.set(realNodeId, pinnedPos);
|
||||
}
|
||||
|
||||
if (preferLocalPositionNodeIdsRef.current.has(optimisticNodeId)) {
|
||||
preferLocalPositionNodeIdsRef.current.delete(optimisticNodeId);
|
||||
preferLocalPositionNodeIdsRef.current.add(realNodeId);
|
||||
}
|
||||
|
||||
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
|
||||
await remapCanvasSyncNodeId(canvasId as string, optimisticNodeId, realNodeId);
|
||||
remapCanvasOpNodeId(canvasId as string, optimisticNodeId, realNodeId);
|
||||
}, [canvasId]);
|
||||
|
||||
const runCreateNodeOnlineOnly = useCallback(
|
||||
async (args: Parameters<typeof createNode>[0]) => {
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Node erstellen");
|
||||
throw new Error("offline-unsupported");
|
||||
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||
const payload = { ...args, clientRequestId };
|
||||
|
||||
if (isSyncOnline) {
|
||||
return await createNode(payload);
|
||||
}
|
||||
return await createNode(args);
|
||||
|
||||
const optimisticNodeId = addOptimisticNodeLocally(payload);
|
||||
await enqueueSyncMutationRef.current("createNode", payload);
|
||||
return optimisticNodeId;
|
||||
},
|
||||
[createNode, isSyncOnline, notifyOfflineUnsupported],
|
||||
[addOptimisticNodeLocally, createNode, isSyncOnline],
|
||||
);
|
||||
|
||||
const runCreateNodeWithEdgeFromSourceOnlineOnly = useCallback(
|
||||
async (args: Parameters<typeof createNodeWithEdgeFromSource>[0]) => {
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Node mit Verbindung erstellen");
|
||||
throw new Error("offline-unsupported");
|
||||
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||
const payload = { ...args, clientRequestId };
|
||||
|
||||
pendingConnectionCreatesRef.current.add(clientRequestId);
|
||||
if (isSyncOnline) {
|
||||
return await createNodeWithEdgeFromSource(payload);
|
||||
}
|
||||
return await createNodeWithEdgeFromSource(args);
|
||||
|
||||
const optimisticNodeId = addOptimisticNodeLocally(payload);
|
||||
addOptimisticEdgeLocally({
|
||||
clientRequestId,
|
||||
sourceNodeId: payload.sourceNodeId,
|
||||
targetNodeId: optimisticNodeId,
|
||||
sourceHandle: payload.sourceHandle,
|
||||
targetHandle: payload.targetHandle,
|
||||
});
|
||||
await enqueueSyncMutationRef.current(
|
||||
"createNodeWithEdgeFromSource",
|
||||
payload,
|
||||
);
|
||||
return optimisticNodeId;
|
||||
},
|
||||
[createNodeWithEdgeFromSource, isSyncOnline, notifyOfflineUnsupported],
|
||||
[
|
||||
addOptimisticEdgeLocally,
|
||||
addOptimisticNodeLocally,
|
||||
createNodeWithEdgeFromSource,
|
||||
isSyncOnline,
|
||||
],
|
||||
);
|
||||
|
||||
const runCreateNodeWithEdgeToTargetOnlineOnly = useCallback(
|
||||
async (args: Parameters<typeof createNodeWithEdgeToTarget>[0]) => {
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Node mit Verbindung erstellen");
|
||||
throw new Error("offline-unsupported");
|
||||
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||
const payload = { ...args, clientRequestId };
|
||||
|
||||
pendingConnectionCreatesRef.current.add(clientRequestId);
|
||||
if (isSyncOnline) {
|
||||
return await createNodeWithEdgeToTarget(payload);
|
||||
}
|
||||
return await createNodeWithEdgeToTarget(args);
|
||||
|
||||
const optimisticNodeId = addOptimisticNodeLocally(payload);
|
||||
addOptimisticEdgeLocally({
|
||||
clientRequestId,
|
||||
sourceNodeId: optimisticNodeId,
|
||||
targetNodeId: payload.targetNodeId,
|
||||
sourceHandle: payload.sourceHandle,
|
||||
targetHandle: payload.targetHandle,
|
||||
});
|
||||
await enqueueSyncMutationRef.current("createNodeWithEdgeToTarget", payload);
|
||||
return optimisticNodeId;
|
||||
},
|
||||
[createNodeWithEdgeToTarget, isSyncOnline, notifyOfflineUnsupported],
|
||||
[
|
||||
addOptimisticEdgeLocally,
|
||||
addOptimisticNodeLocally,
|
||||
createNodeWithEdgeToTarget,
|
||||
isSyncOnline,
|
||||
],
|
||||
);
|
||||
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = useCallback(
|
||||
@@ -547,15 +763,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const queue = await listCanvasSyncOps(canvasId as string);
|
||||
let permanentFailures = 0;
|
||||
let processedInThisPass = 0;
|
||||
|
||||
for (const op of queue) {
|
||||
if (op.expiresAt <= now) continue;
|
||||
if (op.nextRetryAt > now) continue;
|
||||
while (processedInThisPass < 500) {
|
||||
const nowLoop = Date.now();
|
||||
const queue = await listCanvasSyncOps(canvasId as string);
|
||||
const op = queue.find(
|
||||
(entry) => entry.expiresAt > nowLoop && entry.nextRetryAt <= nowLoop,
|
||||
);
|
||||
if (!op) break;
|
||||
processedInThisPass += 1;
|
||||
|
||||
try {
|
||||
if (op.type === "moveNode") {
|
||||
if (op.type === "createNode") {
|
||||
const realId = await createNodeRaw(op.payload);
|
||||
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
||||
await syncPendingMoveForClientRequestRef.current(
|
||||
op.payload.clientRequestId,
|
||||
realId,
|
||||
);
|
||||
setEdgeSyncNonce((value) => value + 1);
|
||||
} else if (op.type === "createNodeWithEdgeFromSource") {
|
||||
const realId = await createNodeWithEdgeFromSourceRaw(op.payload);
|
||||
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
||||
await syncPendingMoveForClientRequestRef.current(
|
||||
op.payload.clientRequestId,
|
||||
realId,
|
||||
);
|
||||
setEdgeSyncNonce((value) => value + 1);
|
||||
} else if (op.type === "createNodeWithEdgeToTarget") {
|
||||
const realId = await createNodeWithEdgeToTargetRaw(op.payload);
|
||||
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
||||
await syncPendingMoveForClientRequestRef.current(
|
||||
op.payload.clientRequestId,
|
||||
realId,
|
||||
);
|
||||
setEdgeSyncNonce((value) => value + 1);
|
||||
} else if (op.type === "createEdge") {
|
||||
await createEdgeRaw(op.payload);
|
||||
} else if (op.type === "moveNode") {
|
||||
await moveNode(op.payload);
|
||||
} else if (op.type === "resizeNode") {
|
||||
await resizeNode(op.payload);
|
||||
@@ -578,6 +825,26 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
}
|
||||
|
||||
permanentFailures += 1;
|
||||
if (op.type === "createNode") {
|
||||
removeOptimisticCreateLocally({
|
||||
clientRequestId: op.payload.clientRequestId,
|
||||
removeNode: true,
|
||||
});
|
||||
} else if (
|
||||
op.type === "createNodeWithEdgeFromSource" ||
|
||||
op.type === "createNodeWithEdgeToTarget"
|
||||
) {
|
||||
removeOptimisticCreateLocally({
|
||||
clientRequestId: op.payload.clientRequestId,
|
||||
removeNode: true,
|
||||
removeEdge: true,
|
||||
});
|
||||
} else if (op.type === "createEdge") {
|
||||
removeOptimisticCreateLocally({
|
||||
clientRequestId: op.payload.clientRequestId,
|
||||
removeEdge: true,
|
||||
});
|
||||
}
|
||||
await ackCanvasSyncOp(op.id);
|
||||
resolveCanvasOp(canvasId as string, op.id);
|
||||
}
|
||||
@@ -594,7 +861,20 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
setIsSyncing(false);
|
||||
await refreshPendingSyncCount();
|
||||
}
|
||||
}, [canvasId, isSyncOnline, moveNode, refreshPendingSyncCount, resizeNode, updateNodeData]);
|
||||
}, [
|
||||
canvasId,
|
||||
createEdgeRaw,
|
||||
createNodeRaw,
|
||||
createNodeWithEdgeFromSourceRaw,
|
||||
createNodeWithEdgeToTargetRaw,
|
||||
isSyncOnline,
|
||||
moveNode,
|
||||
refreshPendingSyncCount,
|
||||
remapOptimisticNodeLocally,
|
||||
removeOptimisticCreateLocally,
|
||||
resizeNode,
|
||||
updateNodeData,
|
||||
]);
|
||||
|
||||
const enqueueSyncMutation = useCallback(
|
||||
async <TType extends keyof CanvasSyncOpPayloadByType>(
|
||||
@@ -622,6 +902,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
},
|
||||
[canvasId, flushCanvasSyncQueue, refreshPendingSyncCount],
|
||||
);
|
||||
enqueueSyncMutationRef.current = enqueueSyncMutation;
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPendingSyncCount();
|
||||
@@ -699,13 +980,24 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
|
||||
const runCreateEdgeMutation = useCallback(
|
||||
async (args: Parameters<typeof createEdge>[0]) => {
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Kante erstellen");
|
||||
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||
const payload = { ...args, clientRequestId };
|
||||
|
||||
if (isSyncOnline) {
|
||||
await createEdge(payload);
|
||||
return;
|
||||
}
|
||||
await createEdge(args);
|
||||
|
||||
addOptimisticEdgeLocally({
|
||||
clientRequestId,
|
||||
sourceNodeId: payload.sourceNodeId,
|
||||
targetNodeId: payload.targetNodeId,
|
||||
sourceHandle: payload.sourceHandle,
|
||||
targetHandle: payload.targetHandle,
|
||||
});
|
||||
await enqueueSyncMutation("createEdge", payload);
|
||||
},
|
||||
[createEdge, isSyncOnline, notifyOfflineUnsupported],
|
||||
[addOptimisticEdgeLocally, createEdge, enqueueSyncMutation, isSyncOnline],
|
||||
);
|
||||
|
||||
const runRemoveEdgeMutation = useCallback(
|
||||
@@ -795,9 +1087,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
);
|
||||
|
||||
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
|
||||
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo(
|
||||
() => ({
|
||||
targetNodeId: assetBrowserTargetNodeId,
|
||||
@@ -816,6 +1105,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
if (!clientRequestId) return;
|
||||
|
||||
if (realId !== undefined) {
|
||||
if (isOptimisticNodeId(realId as string)) {
|
||||
return;
|
||||
}
|
||||
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
||||
setAssetBrowserTargetNodeId((current) =>
|
||||
current === optimisticNodeId ? (realId as string) : current,
|
||||
@@ -919,15 +1211,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
},
|
||||
[canvasId, runMoveNodeMutation, runSplitEdgeAtExistingNodeMutation],
|
||||
);
|
||||
syncPendingMoveForClientRequestRef.current = syncPendingMoveForClientRequest;
|
||||
|
||||
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||
const nodesRef = useRef<RFNode[]>(nodes);
|
||||
nodesRef.current = nodes;
|
||||
const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false);
|
||||
/** Erzwingt Edge-Merge nach Mutation, falls clientRequestId→realId-Ref erst im Promise gesetzt wird. */
|
||||
const [edgeSyncNonce, setEdgeSyncNonce] = useState(0);
|
||||
const [connectionDropMenu, setConnectionDropMenu] =
|
||||
useState<ConnectionDropMenuState | null>(null);
|
||||
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
||||
@@ -1005,8 +1294,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
|
||||
// Drag-Lock: während des Drags kein Convex-Override
|
||||
const isDragging = useRef(false);
|
||||
/** Convex-Merge: Position nicht mit veraltetem Snapshot überschreiben (RF-`dragging` kommt oft verzögert). */
|
||||
const preferLocalPositionNodeIdsRef = useRef(new Set<string>());
|
||||
// Resize-Lock: kein Convex→lokal während aktiver Größenänderung (veraltete Maße überschreiben sonst den Resize)
|
||||
const isResizing = useRef(false);
|
||||
|
||||
@@ -1717,10 +2004,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
|
||||
const handleConnectionDropPick = useCallback(
|
||||
(template: CanvasNodeTemplate) => {
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Node mit Verbindung erstellen");
|
||||
return;
|
||||
}
|
||||
const ctx = connectionDropMenuRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
@@ -1767,6 +2050,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
targetHandle: handles?.target ?? undefined,
|
||||
})
|
||||
.then((realId) => {
|
||||
if (isOptimisticNodeId(realId as string)) {
|
||||
return;
|
||||
}
|
||||
resolvedRealIdByClientRequestRef.current.set(
|
||||
clientRequestId,
|
||||
realId,
|
||||
@@ -1786,6 +2072,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
targetHandle: ctx.fromHandleId,
|
||||
})
|
||||
.then((realId) => {
|
||||
if (isOptimisticNodeId(realId as string)) {
|
||||
return;
|
||||
}
|
||||
resolvedRealIdByClientRequestRef.current.set(
|
||||
clientRequestId,
|
||||
realId,
|
||||
@@ -1801,8 +2090,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
},
|
||||
[
|
||||
canvasId,
|
||||
isSyncOnline,
|
||||
notifyOfflineUnsupported,
|
||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||
syncPendingMoveForClientRequest,
|
||||
@@ -1818,10 +2105,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const onDrop = useCallback(
|
||||
async (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Node erstellen");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData = event.dataTransfer.getData(
|
||||
"application/lemonspace-node-type",
|
||||
@@ -1829,6 +2112,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
if (!rawData) {
|
||||
const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0;
|
||||
if (hasFiles) {
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Upload per Drag-and-drop");
|
||||
return;
|
||||
}
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file.type.startsWith("image/")) {
|
||||
try {
|
||||
@@ -1944,8 +2231,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
canvasId,
|
||||
generateUploadUrl,
|
||||
isSyncOnline,
|
||||
notifyOfflineUnsupported,
|
||||
runCreateNodeOnlineOnly,
|
||||
notifyOfflineUnsupported,
|
||||
syncPendingMoveForClientRequest,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { requireAuth } from "./helpers";
|
||||
import { optionalAuth, requireAuth } from "./helpers";
|
||||
|
||||
// ============================================================================
|
||||
// Queries
|
||||
@@ -27,7 +27,10 @@ export const list = query({
|
||||
export const get = query({
|
||||
args: { canvasId: v.id("canvases") },
|
||||
handler: async (ctx, { canvasId }) => {
|
||||
const user = await requireAuth(ctx);
|
||||
const user = await optionalAuth(ctx);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const canvas = await ctx.db.get(canvasId);
|
||||
if (!canvas || canvas.ownerId !== user.userId) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { requireAuth } from "./helpers";
|
||||
import { optionalAuth, requireAuth } from "./helpers";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
// ============================================================================
|
||||
@@ -58,7 +58,10 @@ export type Tier = keyof typeof TIER_CONFIG;
|
||||
export const getBalance = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const user = await requireAuth(ctx);
|
||||
const user = await optionalAuth(ctx);
|
||||
if (!user) {
|
||||
return { balance: 0, reserved: 0, available: 0, monthlyAllocation: 0 };
|
||||
}
|
||||
const balance = await ctx.db
|
||||
.query("creditBalances")
|
||||
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { requireAuth } from "./helpers";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
// ============================================================================
|
||||
// Queries
|
||||
@@ -39,6 +40,7 @@ export const create = mutation({
|
||||
targetNodeId: v.id("nodes"),
|
||||
sourceHandle: v.optional(v.string()),
|
||||
targetHandle: v.optional(v.string()),
|
||||
clientRequestId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await requireAuth(ctx);
|
||||
@@ -47,6 +49,31 @@ export const create = mutation({
|
||||
throw new Error("Canvas not found");
|
||||
}
|
||||
|
||||
const getExistingEdge = async (): Promise<Id<"edges"> | null> => {
|
||||
const clientRequestId = args.clientRequestId;
|
||||
if (!clientRequestId) return null;
|
||||
const existing = await ctx.db
|
||||
.query("mutationRequests")
|
||||
.withIndex("by_user_mutation_request", (q) =>
|
||||
q
|
||||
.eq("userId", user.userId)
|
||||
.eq("mutation", "edges.create")
|
||||
.eq("clientRequestId", clientRequestId),
|
||||
)
|
||||
.first();
|
||||
if (!existing) return null;
|
||||
if (existing.canvasId && existing.canvasId !== args.canvasId) {
|
||||
throw new Error("Client request conflict");
|
||||
}
|
||||
if (!existing.edgeId) return null;
|
||||
return existing.edgeId;
|
||||
};
|
||||
|
||||
const existingEdgeId = await getExistingEdge();
|
||||
if (existingEdgeId) {
|
||||
return existingEdgeId;
|
||||
}
|
||||
|
||||
// Prüfen ob beide Nodes existieren und zum gleichen Canvas gehören
|
||||
const source = await ctx.db.get(args.sourceNodeId);
|
||||
const target = await ctx.db.get(args.targetNodeId);
|
||||
@@ -71,6 +98,16 @@ export const create = mutation({
|
||||
});
|
||||
|
||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||
if (args.clientRequestId) {
|
||||
await ctx.db.insert("mutationRequests", {
|
||||
userId: user.userId,
|
||||
mutation: "edges.create",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
edgeId,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return edgeId;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,6 +38,16 @@ export async function requireAuth(
|
||||
/**
|
||||
* Gibt den User zurück oder null — für optionale Auth-Checks (z.B. public Queries).
|
||||
*/
|
||||
export async function optionalAuth(ctx: QueryCtx | MutationCtx) {
|
||||
return await authComponent.safeGetAuthUser(ctx);
|
||||
export async function optionalAuth(
|
||||
ctx: QueryCtx | MutationCtx
|
||||
): Promise<AuthUser | null> {
|
||||
const user = await authComponent.safeGetAuthUser(ctx);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const userId = user.userId ?? String(user._id);
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
return { ...user, userId };
|
||||
}
|
||||
|
||||
109
convex/nodes.ts
109
convex/nodes.ts
@@ -34,6 +34,62 @@ async function getCanvasIfAuthorized(
|
||||
return canvas;
|
||||
}
|
||||
|
||||
type NodeCreateMutationName =
|
||||
| "nodes.create"
|
||||
| "nodes.createWithEdgeFromSource"
|
||||
| "nodes.createWithEdgeToTarget";
|
||||
|
||||
async function getIdempotentNodeCreateResult(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
userId: string;
|
||||
mutation: NodeCreateMutationName;
|
||||
clientRequestId?: string;
|
||||
canvasId: Id<"canvases">;
|
||||
},
|
||||
): Promise<Id<"nodes"> | null> {
|
||||
const clientRequestId = args.clientRequestId;
|
||||
if (!clientRequestId) return null;
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("mutationRequests")
|
||||
.withIndex("by_user_mutation_request", (q) =>
|
||||
q
|
||||
.eq("userId", args.userId)
|
||||
.eq("mutation", args.mutation)
|
||||
.eq("clientRequestId", clientRequestId),
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!existing) return null;
|
||||
if (existing.canvasId && existing.canvasId !== args.canvasId) {
|
||||
throw new Error("Client request conflict");
|
||||
}
|
||||
if (!existing.nodeId) return null;
|
||||
return existing.nodeId;
|
||||
}
|
||||
|
||||
async function rememberIdempotentNodeCreateResult(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
userId: string;
|
||||
mutation: NodeCreateMutationName;
|
||||
clientRequestId?: string;
|
||||
canvasId: Id<"canvases">;
|
||||
nodeId: Id<"nodes">;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!args.clientRequestId) return;
|
||||
await ctx.db.insert("mutationRequests", {
|
||||
userId: args.userId,
|
||||
mutation: args.mutation,
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
nodeId: args.nodeId,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Queries
|
||||
// ============================================================================
|
||||
@@ -135,7 +191,15 @@ export const create = mutation({
|
||||
const user = await requireAuth(ctx);
|
||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
||||
|
||||
void args.clientRequestId;
|
||||
const existingNodeId = await getIdempotentNodeCreateResult(ctx, {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.create",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
});
|
||||
if (existingNodeId) {
|
||||
return existingNodeId;
|
||||
}
|
||||
|
||||
const nodeId = await ctx.db.insert("nodes", {
|
||||
canvasId: args.canvasId,
|
||||
@@ -153,6 +217,13 @@ export const create = mutation({
|
||||
|
||||
// Canvas updatedAt aktualisieren
|
||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||
await rememberIdempotentNodeCreateResult(ctx, {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.create",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
nodeId,
|
||||
});
|
||||
|
||||
return nodeId;
|
||||
},
|
||||
@@ -315,7 +386,16 @@ export const createWithEdgeFromSource = mutation({
|
||||
handler: async (ctx, args) => {
|
||||
const user = await requireAuth(ctx);
|
||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
||||
void args.clientRequestId;
|
||||
|
||||
const existingNodeId = await getIdempotentNodeCreateResult(ctx, {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.createWithEdgeFromSource",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
});
|
||||
if (existingNodeId) {
|
||||
return existingNodeId;
|
||||
}
|
||||
|
||||
const source = await ctx.db.get(args.sourceNodeId);
|
||||
if (!source || source.canvasId !== args.canvasId) {
|
||||
@@ -345,6 +425,13 @@ export const createWithEdgeFromSource = mutation({
|
||||
});
|
||||
|
||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||
await rememberIdempotentNodeCreateResult(ctx, {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.createWithEdgeFromSource",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
nodeId,
|
||||
});
|
||||
|
||||
return nodeId;
|
||||
},
|
||||
@@ -373,7 +460,16 @@ export const createWithEdgeToTarget = mutation({
|
||||
handler: async (ctx, args) => {
|
||||
const user = await requireAuth(ctx);
|
||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
||||
void args.clientRequestId;
|
||||
|
||||
const existingNodeId = await getIdempotentNodeCreateResult(ctx, {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.createWithEdgeToTarget",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
});
|
||||
if (existingNodeId) {
|
||||
return existingNodeId;
|
||||
}
|
||||
|
||||
const target = await ctx.db.get(args.targetNodeId);
|
||||
if (!target || target.canvasId !== args.canvasId) {
|
||||
@@ -403,6 +499,13 @@ export const createWithEdgeToTarget = mutation({
|
||||
});
|
||||
|
||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||
await rememberIdempotentNodeCreateResult(ctx, {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.createWithEdgeToTarget",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
nodeId,
|
||||
});
|
||||
|
||||
return nodeId;
|
||||
},
|
||||
|
||||
@@ -214,6 +214,20 @@ export default defineSchema({
|
||||
.index("by_source", ["sourceNodeId"])
|
||||
.index("by_target", ["targetNodeId"]),
|
||||
|
||||
mutationRequests: defineTable({
|
||||
userId: v.string(),
|
||||
mutation: v.string(),
|
||||
clientRequestId: v.string(),
|
||||
canvasId: v.optional(v.id("canvases")),
|
||||
nodeId: v.optional(v.id("nodes")),
|
||||
edgeId: v.optional(v.id("edges")),
|
||||
createdAt: v.number(),
|
||||
}).index("by_user_mutation_request", [
|
||||
"userId",
|
||||
"mutation",
|
||||
"clientRequestId",
|
||||
]),
|
||||
|
||||
// ==========================================================================
|
||||
// Credit-System
|
||||
// ==========================================================================
|
||||
|
||||
@@ -181,3 +181,64 @@ export function resolveCanvasOps(canvasId: string, opIds: string[]): void {
|
||||
export function readCanvasOps(canvasId: string): CanvasPendingOp[] {
|
||||
return readOpsPayload(canvasId).ops;
|
||||
}
|
||||
|
||||
function remapNodeIdInPayload(
|
||||
payload: unknown,
|
||||
fromNodeId: string,
|
||||
toNodeId: string,
|
||||
): { payload: unknown; changed: boolean } {
|
||||
if (!isRecord(payload)) return { payload, changed: false };
|
||||
|
||||
let changed = false;
|
||||
const nextPayload: JsonRecord = { ...payload };
|
||||
|
||||
for (const key of ["nodeId", "sourceNodeId", "targetNodeId", "parentId"] as const) {
|
||||
if (nextPayload[key] === fromNodeId) {
|
||||
nextPayload[key] = toNodeId;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const moves = nextPayload.moves;
|
||||
if (Array.isArray(moves)) {
|
||||
const remappedMoves = moves.map((move) => {
|
||||
if (!isRecord(move)) return move;
|
||||
if (move.nodeId !== fromNodeId) return move;
|
||||
changed = true;
|
||||
return {
|
||||
...move,
|
||||
nodeId: toNodeId,
|
||||
};
|
||||
});
|
||||
nextPayload.moves = remappedMoves;
|
||||
}
|
||||
|
||||
return { payload: changed ? nextPayload : payload, changed };
|
||||
}
|
||||
|
||||
export function remapCanvasOpNodeId(
|
||||
canvasId: string,
|
||||
fromNodeId: string,
|
||||
toNodeId: string,
|
||||
): number {
|
||||
if (fromNodeId === toNodeId) return 0;
|
||||
|
||||
const payload = readOpsPayload(canvasId);
|
||||
let changedCount = 0;
|
||||
|
||||
payload.ops = payload.ops.map((op) => {
|
||||
const remapped = remapNodeIdInPayload(op.payload, fromNodeId, toNodeId);
|
||||
if (!remapped.changed) return op;
|
||||
changedCount += 1;
|
||||
return {
|
||||
...op,
|
||||
payload: remapped.payload,
|
||||
};
|
||||
});
|
||||
|
||||
if (changedCount === 0) return 0;
|
||||
|
||||
payload.updatedAt = Date.now();
|
||||
writePayload(opsKey(canvasId), payload);
|
||||
return changedCount;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,56 @@ const FALLBACK_STORAGE_KEY = "lemonspace.canvas:sync-fallback:v1";
|
||||
export const CANVAS_SYNC_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type CanvasSyncOpPayloadByType = {
|
||||
createNode: {
|
||||
canvasId: Id<"canvases">;
|
||||
type: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
data: unknown;
|
||||
parentId?: Id<"nodes">;
|
||||
zIndex?: number;
|
||||
clientRequestId: string;
|
||||
};
|
||||
createNodeWithEdgeFromSource: {
|
||||
canvasId: Id<"canvases">;
|
||||
type: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
data: unknown;
|
||||
parentId?: Id<"nodes">;
|
||||
zIndex?: number;
|
||||
clientRequestId: string;
|
||||
sourceNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
};
|
||||
createNodeWithEdgeToTarget: {
|
||||
canvasId: Id<"canvases">;
|
||||
type: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
data: unknown;
|
||||
parentId?: Id<"nodes">;
|
||||
zIndex?: number;
|
||||
clientRequestId: string;
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
};
|
||||
createEdge: {
|
||||
canvasId: Id<"canvases">;
|
||||
sourceNodeId: Id<"nodes">;
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
clientRequestId: string;
|
||||
};
|
||||
moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number };
|
||||
resizeNode: { nodeId: Id<"nodes">; width: number; height: number };
|
||||
updateData: { nodeId: Id<"nodes">; data: unknown };
|
||||
@@ -156,7 +206,13 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
||||
!id ||
|
||||
typeof canvasId !== "string" ||
|
||||
!canvasId ||
|
||||
(type !== "moveNode" && type !== "resizeNode" && type !== "updateData")
|
||||
type !== "createNode" &&
|
||||
type !== "createNodeWithEdgeFromSource" &&
|
||||
type !== "createNodeWithEdgeToTarget" &&
|
||||
type !== "createEdge" &&
|
||||
type !== "moveNode" &&
|
||||
type !== "resizeNode" &&
|
||||
type !== "updateData"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -173,6 +229,170 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
||||
|
||||
if (!isRecord(payload)) return null;
|
||||
|
||||
if (
|
||||
type === "createNode" &&
|
||||
typeof payload.canvasId === "string" &&
|
||||
typeof payload.type === "string" &&
|
||||
typeof payload.positionX === "number" &&
|
||||
typeof payload.positionY === "number" &&
|
||||
typeof payload.width === "number" &&
|
||||
typeof payload.height === "number" &&
|
||||
typeof payload.clientRequestId === "string"
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
canvasId,
|
||||
type,
|
||||
payload: {
|
||||
canvasId: payload.canvasId as Id<"canvases">,
|
||||
type: payload.type,
|
||||
positionX: payload.positionX,
|
||||
positionY: payload.positionY,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
data: payload.data,
|
||||
parentId:
|
||||
typeof payload.parentId === "string"
|
||||
? (payload.parentId as Id<"nodes">)
|
||||
: undefined,
|
||||
zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined,
|
||||
clientRequestId: payload.clientRequestId,
|
||||
},
|
||||
enqueuedAt,
|
||||
attemptCount,
|
||||
nextRetryAt,
|
||||
expiresAt,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === "createNodeWithEdgeFromSource" &&
|
||||
typeof payload.canvasId === "string" &&
|
||||
typeof payload.type === "string" &&
|
||||
typeof payload.positionX === "number" &&
|
||||
typeof payload.positionY === "number" &&
|
||||
typeof payload.width === "number" &&
|
||||
typeof payload.height === "number" &&
|
||||
typeof payload.clientRequestId === "string" &&
|
||||
typeof payload.sourceNodeId === "string"
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
canvasId,
|
||||
type,
|
||||
payload: {
|
||||
canvasId: payload.canvasId as Id<"canvases">,
|
||||
type: payload.type,
|
||||
positionX: payload.positionX,
|
||||
positionY: payload.positionY,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
data: payload.data,
|
||||
parentId:
|
||||
typeof payload.parentId === "string"
|
||||
? (payload.parentId as Id<"nodes">)
|
||||
: undefined,
|
||||
zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined,
|
||||
clientRequestId: payload.clientRequestId,
|
||||
sourceNodeId: payload.sourceNodeId as Id<"nodes">,
|
||||
sourceHandle:
|
||||
typeof payload.sourceHandle === "string"
|
||||
? payload.sourceHandle
|
||||
: undefined,
|
||||
targetHandle:
|
||||
typeof payload.targetHandle === "string"
|
||||
? payload.targetHandle
|
||||
: undefined,
|
||||
},
|
||||
enqueuedAt,
|
||||
attemptCount,
|
||||
nextRetryAt,
|
||||
expiresAt,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === "createNodeWithEdgeToTarget" &&
|
||||
typeof payload.canvasId === "string" &&
|
||||
typeof payload.type === "string" &&
|
||||
typeof payload.positionX === "number" &&
|
||||
typeof payload.positionY === "number" &&
|
||||
typeof payload.width === "number" &&
|
||||
typeof payload.height === "number" &&
|
||||
typeof payload.clientRequestId === "string" &&
|
||||
typeof payload.targetNodeId === "string"
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
canvasId,
|
||||
type,
|
||||
payload: {
|
||||
canvasId: payload.canvasId as Id<"canvases">,
|
||||
type: payload.type,
|
||||
positionX: payload.positionX,
|
||||
positionY: payload.positionY,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
data: payload.data,
|
||||
parentId:
|
||||
typeof payload.parentId === "string"
|
||||
? (payload.parentId as Id<"nodes">)
|
||||
: undefined,
|
||||
zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined,
|
||||
clientRequestId: payload.clientRequestId,
|
||||
targetNodeId: payload.targetNodeId as Id<"nodes">,
|
||||
sourceHandle:
|
||||
typeof payload.sourceHandle === "string"
|
||||
? payload.sourceHandle
|
||||
: undefined,
|
||||
targetHandle:
|
||||
typeof payload.targetHandle === "string"
|
||||
? payload.targetHandle
|
||||
: undefined,
|
||||
},
|
||||
enqueuedAt,
|
||||
attemptCount,
|
||||
nextRetryAt,
|
||||
expiresAt,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === "createEdge" &&
|
||||
typeof payload.canvasId === "string" &&
|
||||
typeof payload.sourceNodeId === "string" &&
|
||||
typeof payload.targetNodeId === "string" &&
|
||||
typeof payload.clientRequestId === "string"
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
canvasId,
|
||||
type,
|
||||
payload: {
|
||||
canvasId: payload.canvasId as Id<"canvases">,
|
||||
sourceNodeId: payload.sourceNodeId as Id<"nodes">,
|
||||
targetNodeId: payload.targetNodeId as Id<"nodes">,
|
||||
sourceHandle:
|
||||
typeof payload.sourceHandle === "string"
|
||||
? payload.sourceHandle
|
||||
: undefined,
|
||||
targetHandle:
|
||||
typeof payload.targetHandle === "string"
|
||||
? payload.targetHandle
|
||||
: undefined,
|
||||
clientRequestId: payload.clientRequestId,
|
||||
},
|
||||
enqueuedAt,
|
||||
attemptCount,
|
||||
nextRetryAt,
|
||||
expiresAt,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === "moveNode" &&
|
||||
typeof payload.nodeId === "string" &&
|
||||
@@ -418,3 +638,112 @@ export async function dropExpiredCanvasSyncOps(
|
||||
await txDone(tx);
|
||||
return expiredIds;
|
||||
}
|
||||
|
||||
function remapNodeIdInPayload(
|
||||
op: CanvasSyncOp,
|
||||
fromNodeId: string,
|
||||
toNodeId: string,
|
||||
): CanvasSyncOp {
|
||||
if (op.type === "createNode" && op.payload.parentId === fromNodeId) {
|
||||
return {
|
||||
...op,
|
||||
payload: { ...op.payload, parentId: toNodeId as Id<"nodes"> },
|
||||
};
|
||||
}
|
||||
if (op.type === "createNodeWithEdgeFromSource") {
|
||||
let changed = false;
|
||||
const next = { ...op.payload };
|
||||
if (next.parentId === fromNodeId) {
|
||||
next.parentId = toNodeId as Id<"nodes">;
|
||||
changed = true;
|
||||
}
|
||||
if (next.sourceNodeId === fromNodeId) {
|
||||
next.sourceNodeId = toNodeId as Id<"nodes">;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
return { ...op, payload: next };
|
||||
}
|
||||
}
|
||||
if (op.type === "createNodeWithEdgeToTarget") {
|
||||
let changed = false;
|
||||
const next = { ...op.payload };
|
||||
if (next.parentId === fromNodeId) {
|
||||
next.parentId = toNodeId as Id<"nodes">;
|
||||
changed = true;
|
||||
}
|
||||
if (next.targetNodeId === fromNodeId) {
|
||||
next.targetNodeId = toNodeId as Id<"nodes">;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
return { ...op, payload: next };
|
||||
}
|
||||
}
|
||||
if (op.type === "moveNode" && op.payload.nodeId === fromNodeId) {
|
||||
return {
|
||||
...op,
|
||||
payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> },
|
||||
};
|
||||
}
|
||||
if (op.type === "resizeNode" && op.payload.nodeId === fromNodeId) {
|
||||
return {
|
||||
...op,
|
||||
payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> },
|
||||
};
|
||||
}
|
||||
if (op.type === "updateData" && op.payload.nodeId === fromNodeId) {
|
||||
return {
|
||||
...op,
|
||||
payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> },
|
||||
};
|
||||
}
|
||||
if (op.type === "createEdge") {
|
||||
let changed = false;
|
||||
const next = { ...op.payload };
|
||||
if (next.sourceNodeId === fromNodeId) {
|
||||
next.sourceNodeId = toNodeId as Id<"nodes">;
|
||||
changed = true;
|
||||
}
|
||||
if (next.targetNodeId === fromNodeId) {
|
||||
next.targetNodeId = toNodeId as Id<"nodes">;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
return { ...op, payload: next };
|
||||
}
|
||||
}
|
||||
return op;
|
||||
}
|
||||
|
||||
export async function remapCanvasSyncNodeId(
|
||||
canvasId: string,
|
||||
fromNodeId: string,
|
||||
toNodeId: string,
|
||||
): Promise<number> {
|
||||
const queue = await listCanvasSyncOps(canvasId);
|
||||
let changed = 0;
|
||||
const nextOps = queue.map((entry) => {
|
||||
const next = remapNodeIdInPayload(entry, fromNodeId, toNodeId);
|
||||
if (next !== entry) changed += 1;
|
||||
return next;
|
||||
});
|
||||
if (changed === 0) return 0;
|
||||
|
||||
const db = await openDb();
|
||||
if (!db) {
|
||||
const fallback = readFallbackOps()
|
||||
.filter((entry) => entry.canvasId !== canvasId)
|
||||
.concat(nextOps);
|
||||
writeFallbackOps(fallback);
|
||||
return changed;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
for (const op of nextOps) {
|
||||
store.put(op);
|
||||
}
|
||||
await txDone(tx);
|
||||
return changed;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user