Enable offline delete and reconnect queue sync
This commit is contained in:
@@ -12,11 +12,10 @@ import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils";
|
|||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||||
|
|
||||||
import { getNodeDeleteBlockReason, isOptimisticEdgeId } from "./canvas-helpers";
|
import { getNodeDeleteBlockReason } from "./canvas-helpers";
|
||||||
|
|
||||||
type UseCanvasDeleteHandlersParams = {
|
type UseCanvasDeleteHandlersParams = {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
isOffline: boolean;
|
|
||||||
nodes: RFNode[];
|
nodes: RFNode[];
|
||||||
edges: RFEdge[];
|
edges: RFEdge[];
|
||||||
deletingNodeIds: MutableRefObject<Set<string>>;
|
deletingNodeIds: MutableRefObject<Set<string>>;
|
||||||
@@ -34,7 +33,6 @@ type UseCanvasDeleteHandlersParams = {
|
|||||||
|
|
||||||
export function useCanvasDeleteHandlers({
|
export function useCanvasDeleteHandlers({
|
||||||
canvasId,
|
canvasId,
|
||||||
isOffline,
|
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
deletingNodeIds,
|
deletingNodeIds,
|
||||||
@@ -55,14 +53,6 @@ export function useCanvasDeleteHandlers({
|
|||||||
nodes: RFNode[];
|
nodes: RFNode[];
|
||||||
edges: RFEdge[];
|
edges: RFEdge[];
|
||||||
}) => {
|
}) => {
|
||||||
if (isOffline && (matchingNodes.length > 0 || matchingEdges.length > 0)) {
|
|
||||||
toast.warning(
|
|
||||||
"Offline aktuell nicht unterstützt",
|
|
||||||
"Löschen ist in Stufe 1 nur online verfügbar.",
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchingNodes.length === 0) {
|
if (matchingNodes.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -100,7 +90,7 @@ export function useCanvasDeleteHandlers({
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[isOffline],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onNodesDelete = useCallback(
|
const onNodesDelete = useCallback(
|
||||||
@@ -171,9 +161,6 @@ export function useCanvasDeleteHandlers({
|
|||||||
if (edge.className === "temp") {
|
if (edge.className === "temp") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isOptimisticEdgeId(edge.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function rfEdgeConnectionSignature(edge: RFEdge): string {
|
|||||||
export function getNodeDeleteBlockReason(
|
export function getNodeDeleteBlockReason(
|
||||||
node: RFNode,
|
node: RFNode,
|
||||||
): CanvasNodeDeleteBlockReason | null {
|
): CanvasNodeDeleteBlockReason | null {
|
||||||
if (isOptimisticNodeId(node.id)) return "optimistic";
|
void node;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,52 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useRef } from "react";
|
||||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||||
import { reconnectEdge, type Connection, type Edge as RFEdge } from "@xyflow/react";
|
import { reconnectEdge, type Connection, type Edge as RFEdge } from "@xyflow/react";
|
||||||
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
|
||||||
import { isOptimisticEdgeId } from "./canvas-helpers";
|
|
||||||
|
|
||||||
type UseCanvasReconnectHandlersParams = {
|
type UseCanvasReconnectHandlersParams = {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
edgeReconnectSuccessful: MutableRefObject<boolean>;
|
edgeReconnectSuccessful: MutableRefObject<boolean>;
|
||||||
isReconnectDragActiveRef: MutableRefObject<boolean>;
|
isReconnectDragActiveRef: MutableRefObject<boolean>;
|
||||||
setEdges: Dispatch<SetStateAction<RFEdge[]>>;
|
setEdges: Dispatch<SetStateAction<RFEdge[]>>;
|
||||||
|
runCreateEdgeMutation: (args: {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
sourceNodeId: Id<"nodes">;
|
||||||
|
targetNodeId: Id<"nodes">;
|
||||||
|
sourceHandle?: string;
|
||||||
|
targetHandle?: string;
|
||||||
|
}) => Promise<unknown>;
|
||||||
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCanvasReconnectHandlers({
|
export function useCanvasReconnectHandlers({
|
||||||
|
canvasId,
|
||||||
edgeReconnectSuccessful,
|
edgeReconnectSuccessful,
|
||||||
isReconnectDragActiveRef,
|
isReconnectDragActiveRef,
|
||||||
setEdges,
|
setEdges,
|
||||||
|
runCreateEdgeMutation,
|
||||||
runRemoveEdgeMutation,
|
runRemoveEdgeMutation,
|
||||||
}: UseCanvasReconnectHandlersParams): {
|
}: UseCanvasReconnectHandlersParams): {
|
||||||
onReconnectStart: () => void;
|
onReconnectStart: () => void;
|
||||||
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||||
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void;
|
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void;
|
||||||
} {
|
} {
|
||||||
|
const pendingReconnectRef = useRef<{
|
||||||
|
oldEdge: RFEdge;
|
||||||
|
newConnection: Connection;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const onReconnectStart = useCallback(() => {
|
const onReconnectStart = useCallback(() => {
|
||||||
edgeReconnectSuccessful.current = false;
|
edgeReconnectSuccessful.current = false;
|
||||||
isReconnectDragActiveRef.current = true;
|
isReconnectDragActiveRef.current = true;
|
||||||
|
pendingReconnectRef.current = null;
|
||||||
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
||||||
|
|
||||||
const onReconnect = useCallback(
|
const onReconnect = useCallback(
|
||||||
(oldEdge: RFEdge, newConnection: Connection) => {
|
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||||
edgeReconnectSuccessful.current = true;
|
edgeReconnectSuccessful.current = true;
|
||||||
|
pendingReconnectRef.current = { oldEdge, newConnection };
|
||||||
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
|
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
|
||||||
},
|
},
|
||||||
[edgeReconnectSuccessful, setEdges],
|
[edgeReconnectSuccessful, setEdges],
|
||||||
@@ -40,6 +56,7 @@ export function useCanvasReconnectHandlers({
|
|||||||
(_: MouseEvent | TouchEvent, edge: RFEdge) => {
|
(_: MouseEvent | TouchEvent, edge: RFEdge) => {
|
||||||
try {
|
try {
|
||||||
if (!edgeReconnectSuccessful.current) {
|
if (!edgeReconnectSuccessful.current) {
|
||||||
|
pendingReconnectRef.current = null;
|
||||||
setEdges((currentEdges) =>
|
setEdges((currentEdges) =>
|
||||||
currentEdges.filter((candidate) => candidate.id !== edge.id),
|
currentEdges.filter((candidate) => candidate.id !== edge.id),
|
||||||
);
|
);
|
||||||
@@ -48,10 +65,6 @@ export function useCanvasReconnectHandlers({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOptimisticEdgeId(edge.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error("[Canvas edge remove failed] reconnect end", {
|
console.error("[Canvas edge remove failed] reconnect end", {
|
||||||
@@ -64,12 +77,54 @@ export function useCanvasReconnectHandlers({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingReconnect = pendingReconnectRef.current;
|
||||||
|
pendingReconnectRef.current = null;
|
||||||
|
if (
|
||||||
|
pendingReconnect &&
|
||||||
|
pendingReconnect.newConnection.source &&
|
||||||
|
pendingReconnect.newConnection.target
|
||||||
|
) {
|
||||||
|
void runCreateEdgeMutation({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: pendingReconnect.newConnection.source as Id<"nodes">,
|
||||||
|
targetNodeId: pendingReconnect.newConnection.target as Id<"nodes">,
|
||||||
|
sourceHandle: pendingReconnect.newConnection.sourceHandle ?? undefined,
|
||||||
|
targetHandle: pendingReconnect.newConnection.targetHandle ?? undefined,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("[Canvas edge reconnect failed] create edge", {
|
||||||
|
oldEdgeId: pendingReconnect.oldEdge.id,
|
||||||
|
source: pendingReconnect.newConnection.source,
|
||||||
|
target: pendingReconnect.newConnection.target,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingReconnect.oldEdge.className !== "temp") {
|
||||||
|
void runRemoveEdgeMutation({
|
||||||
|
edgeId: pendingReconnect.oldEdge.id as Id<"edges">,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("[Canvas edge reconnect failed] remove old edge", {
|
||||||
|
oldEdgeId: pendingReconnect.oldEdge.id,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
edgeReconnectSuccessful.current = true;
|
edgeReconnectSuccessful.current = true;
|
||||||
} finally {
|
} finally {
|
||||||
isReconnectDragActiveRef.current = false;
|
isReconnectDragActiveRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[edgeReconnectSuccessful, isReconnectDragActiveRef, runRemoveEdgeMutation, setEdges],
|
[
|
||||||
|
canvasId,
|
||||||
|
edgeReconnectSuccessful,
|
||||||
|
isReconnectDragActiveRef,
|
||||||
|
runCreateEdgeMutation,
|
||||||
|
runRemoveEdgeMutation,
|
||||||
|
setEdges,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { onReconnectStart, onReconnect, onReconnectEnd };
|
return { onReconnectStart, onReconnect, onReconnectEnd };
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import "@xyflow/react/dist/style.css";
|
|||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { msg } from "@/lib/toast-messages";
|
import { msg } from "@/lib/toast-messages";
|
||||||
import {
|
import {
|
||||||
|
dropCanvasOpsByClientRequestIds,
|
||||||
|
dropCanvasOpsByEdgeIds,
|
||||||
|
dropCanvasOpsByNodeIds,
|
||||||
enqueueCanvasOp,
|
enqueueCanvasOp,
|
||||||
readCanvasSnapshot,
|
readCanvasSnapshot,
|
||||||
remapCanvasOpNodeId,
|
remapCanvasOpNodeId,
|
||||||
@@ -43,6 +46,9 @@ import {
|
|||||||
ackCanvasSyncOp,
|
ackCanvasSyncOp,
|
||||||
type CanvasSyncOpPayloadByType,
|
type CanvasSyncOpPayloadByType,
|
||||||
countCanvasSyncOps,
|
countCanvasSyncOps,
|
||||||
|
dropCanvasSyncOpsByClientRequestIds,
|
||||||
|
dropCanvasSyncOpsByEdgeIds,
|
||||||
|
dropCanvasSyncOpsByNodeIds,
|
||||||
dropExpiredCanvasSyncOps,
|
dropExpiredCanvasSyncOps,
|
||||||
enqueueCanvasSyncOp,
|
enqueueCanvasSyncOp,
|
||||||
listCanvasSyncOps,
|
listCanvasSyncOps,
|
||||||
@@ -404,31 +410,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
|
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
|
||||||
|
|
||||||
const batchRemoveNodes = useMutation(api.nodes.batchRemove).withOptimisticUpdate(
|
|
||||||
(localStore, args) => {
|
|
||||||
const nodeList = localStore.getQuery(api.nodes.list, { canvasId });
|
|
||||||
const edgeList = localStore.getQuery(api.edges.list, { canvasId });
|
|
||||||
if (nodeList === undefined || edgeList === undefined) return;
|
|
||||||
|
|
||||||
const removeSet = new Set<string>(
|
|
||||||
args.nodeIds.map((id: Id<"nodes">) => id as string),
|
|
||||||
);
|
|
||||||
localStore.setQuery(
|
|
||||||
api.nodes.list,
|
|
||||||
{ canvasId },
|
|
||||||
nodeList.filter((n: Doc<"nodes">) => !removeSet.has(n._id)),
|
|
||||||
);
|
|
||||||
localStore.setQuery(
|
|
||||||
api.edges.list,
|
|
||||||
{ canvasId },
|
|
||||||
edgeList.filter(
|
|
||||||
(e: Doc<"edges">) =>
|
|
||||||
!removeSet.has(e.sourceNodeId) && !removeSet.has(e.targetNodeId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
|
const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
|
||||||
(localStore, args) => {
|
(localStore, args) => {
|
||||||
const edgeList = localStore.getQuery(api.edges.list, {
|
const edgeList = localStore.getQuery(api.edges.list, {
|
||||||
@@ -465,18 +446,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
api.nodes.createWithEdgeToTarget,
|
api.nodes.createWithEdgeToTarget,
|
||||||
);
|
);
|
||||||
const createEdgeRaw = useMutation(api.edges.create);
|
const createEdgeRaw = useMutation(api.edges.create);
|
||||||
|
const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove);
|
||||||
const removeEdge = useMutation(api.edges.remove).withOptimisticUpdate(
|
const removeEdgeRaw = useMutation(api.edges.remove);
|
||||||
(localStore, args) => {
|
|
||||||
const edgeList = localStore.getQuery(api.edges.list, { canvasId });
|
|
||||||
if (edgeList === undefined) return;
|
|
||||||
localStore.setQuery(
|
|
||||||
api.edges.list,
|
|
||||||
{ canvasId },
|
|
||||||
edgeList.filter((e: Doc<"edges">) => e._id !== args.edgeId),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||||
@@ -802,6 +773,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
setEdgeSyncNonce((value) => value + 1);
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
} else if (op.type === "createEdge") {
|
} else if (op.type === "createEdge") {
|
||||||
await createEdgeRaw(op.payload);
|
await createEdgeRaw(op.payload);
|
||||||
|
} else if (op.type === "removeEdge") {
|
||||||
|
await removeEdgeRaw(op.payload);
|
||||||
|
} else if (op.type === "batchRemoveNodes") {
|
||||||
|
await batchRemoveNodesRaw(op.payload);
|
||||||
} else if (op.type === "moveNode") {
|
} else if (op.type === "moveNode") {
|
||||||
await moveNode(op.payload);
|
await moveNode(op.payload);
|
||||||
} else if (op.type === "resizeNode") {
|
} else if (op.type === "resizeNode") {
|
||||||
@@ -862,6 +837,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
await refreshPendingSyncCount();
|
await refreshPendingSyncCount();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
batchRemoveNodesRaw,
|
||||||
canvasId,
|
canvasId,
|
||||||
createEdgeRaw,
|
createEdgeRaw,
|
||||||
createNodeRaw,
|
createNodeRaw,
|
||||||
@@ -871,6 +847,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
moveNode,
|
moveNode,
|
||||||
refreshPendingSyncCount,
|
refreshPendingSyncCount,
|
||||||
remapOptimisticNodeLocally,
|
remapOptimisticNodeLocally,
|
||||||
|
removeEdgeRaw,
|
||||||
removeOptimisticCreateLocally,
|
removeOptimisticCreateLocally,
|
||||||
resizeNode,
|
resizeNode,
|
||||||
updateNodeData,
|
updateNodeData,
|
||||||
@@ -968,14 +945,61 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runBatchRemoveNodesMutation = useCallback(
|
const runBatchRemoveNodesMutation = useCallback(
|
||||||
async (args: Parameters<typeof batchRemoveNodes>[0]) => {
|
async (args: { nodeIds: Id<"nodes">[] }) => {
|
||||||
if (!isSyncOnline) {
|
const ids = args.nodeIds.map((id) => id as string);
|
||||||
notifyOfflineUnsupported("Löschen");
|
const optimisticNodeIds = ids.filter((id) => isOptimisticNodeId(id));
|
||||||
|
const persistedNodeIds = ids.filter((id) => !isOptimisticNodeId(id));
|
||||||
|
|
||||||
|
const createClientRequestIds = optimisticNodeIds
|
||||||
|
.map((id) => clientRequestIdFromOptimisticNodeId(id))
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
|
||||||
|
if (createClientRequestIds.length > 0) {
|
||||||
|
const droppedSync = await dropCanvasSyncOpsByClientRequestIds(
|
||||||
|
canvasId as string,
|
||||||
|
createClientRequestIds,
|
||||||
|
);
|
||||||
|
const droppedLocal = dropCanvasOpsByClientRequestIds(
|
||||||
|
canvasId as string,
|
||||||
|
createClientRequestIds,
|
||||||
|
);
|
||||||
|
for (const clientRequestId of createClientRequestIds) {
|
||||||
|
removeOptimisticCreateLocally({
|
||||||
|
clientRequestId,
|
||||||
|
removeNode: true,
|
||||||
|
removeEdge: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolveCanvasOps(canvasId as string, droppedSync);
|
||||||
|
resolveCanvasOps(canvasId as string, droppedLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persistedNodeIds.length === 0) {
|
||||||
|
await refreshPendingSyncCount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await batchRemoveNodes(args);
|
|
||||||
|
const droppedSyncByNode = await dropCanvasSyncOpsByNodeIds(
|
||||||
|
canvasId as string,
|
||||||
|
persistedNodeIds,
|
||||||
|
);
|
||||||
|
const droppedLocalByNode = dropCanvasOpsByNodeIds(
|
||||||
|
canvasId as string,
|
||||||
|
persistedNodeIds,
|
||||||
|
);
|
||||||
|
resolveCanvasOps(canvasId as string, droppedSyncByNode);
|
||||||
|
resolveCanvasOps(canvasId as string, droppedLocalByNode);
|
||||||
|
|
||||||
|
await enqueueSyncMutation("batchRemoveNodes", {
|
||||||
|
nodeIds: persistedNodeIds as Id<"nodes">[],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[batchRemoveNodes, isSyncOnline, notifyOfflineUnsupported],
|
[
|
||||||
|
canvasId,
|
||||||
|
enqueueSyncMutation,
|
||||||
|
refreshPendingSyncCount,
|
||||||
|
removeOptimisticCreateLocally,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runCreateEdgeMutation = useCallback(
|
const runCreateEdgeMutation = useCallback(
|
||||||
@@ -1001,14 +1025,37 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runRemoveEdgeMutation = useCallback(
|
const runRemoveEdgeMutation = useCallback(
|
||||||
async (args: Parameters<typeof removeEdge>[0]) => {
|
async (args: { edgeId: Id<"edges"> }) => {
|
||||||
if (!isSyncOnline) {
|
const edgeId = args.edgeId as string;
|
||||||
notifyOfflineUnsupported("Kante entfernen");
|
setEdges((current) => current.filter((edge) => edge.id !== edgeId));
|
||||||
|
if (isOptimisticEdgeId(edgeId)) {
|
||||||
|
const clientRequestId = clientRequestIdFromOptimisticEdgeId(edgeId);
|
||||||
|
if (clientRequestId) {
|
||||||
|
const droppedSync = await dropCanvasSyncOpsByClientRequestIds(
|
||||||
|
canvasId as string,
|
||||||
|
[clientRequestId],
|
||||||
|
);
|
||||||
|
const droppedLocal = dropCanvasOpsByClientRequestIds(
|
||||||
|
canvasId as string,
|
||||||
|
[clientRequestId],
|
||||||
|
);
|
||||||
|
resolveCanvasOps(canvasId as string, droppedSync);
|
||||||
|
resolveCanvasOps(canvasId as string, droppedLocal);
|
||||||
|
}
|
||||||
|
await refreshPendingSyncCount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await removeEdge(args);
|
|
||||||
|
const droppedSync = await dropCanvasSyncOpsByEdgeIds(canvasId as string, [edgeId]);
|
||||||
|
const droppedLocal = dropCanvasOpsByEdgeIds(canvasId as string, [edgeId]);
|
||||||
|
resolveCanvasOps(canvasId as string, droppedSync);
|
||||||
|
resolveCanvasOps(canvasId as string, droppedLocal);
|
||||||
|
|
||||||
|
await enqueueSyncMutation("removeEdge", {
|
||||||
|
edgeId: edgeId as Id<"edges">,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[isSyncOnline, notifyOfflineUnsupported, removeEdge],
|
[canvasId, enqueueSyncMutation, refreshPendingSyncCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
const splitEdgeAtExistingNodeMut = useMutation(
|
const splitEdgeAtExistingNodeMut = useMutation(
|
||||||
@@ -1322,7 +1369,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
|
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
|
||||||
canvasId,
|
canvasId,
|
||||||
isOffline: !isSyncOnline,
|
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
deletingNodeIds,
|
deletingNodeIds,
|
||||||
@@ -1333,9 +1379,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({
|
const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({
|
||||||
|
canvasId,
|
||||||
edgeReconnectSuccessful,
|
edgeReconnectSuccessful,
|
||||||
isReconnectDragActiveRef,
|
isReconnectDragActiveRef,
|
||||||
setEdges,
|
setEdges,
|
||||||
|
runCreateEdgeMutation,
|
||||||
runRemoveEdgeMutation,
|
runRemoveEdgeMutation,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1349,7 +1397,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
if (!convexEdges) return;
|
if (!convexEdges) return;
|
||||||
setEdges((prev) => {
|
setEdges((prev) => {
|
||||||
const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current;
|
const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current;
|
||||||
const currentConvexIdList =
|
const currentConvexIdList: string[] =
|
||||||
convexNodes !== undefined
|
convexNodes !== undefined
|
||||||
? convexNodes.map((n: Doc<"nodes">) => n._id as string)
|
? convexNodes.map((n: Doc<"nodes">) => n._id as string)
|
||||||
: [];
|
: [];
|
||||||
@@ -1362,8 +1410,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const tempEdges = prev.filter((e) => e.className === "temp");
|
const tempEdges = prev.filter((e) => e.className === "temp");
|
||||||
const sourceTypeByNodeId =
|
const sourceTypeByNodeId =
|
||||||
convexNodes !== undefined
|
convexNodes !== undefined
|
||||||
? new Map(
|
? new Map<string, string>(
|
||||||
convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type]),
|
convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type as string]),
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const glowMode = resolvedTheme === "dark" ? "dark" : "light";
|
const glowMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||||
|
|||||||
@@ -742,9 +742,18 @@ export const batchRemove = mutation({
|
|||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
if (nodeIds.length === 0) return;
|
if (nodeIds.length === 0) return;
|
||||||
|
|
||||||
// Canvas-Zugriff über den ersten Node prüfen
|
// Idempotent: wenn alle Nodes bereits weg sind, no-op.
|
||||||
const firstNode = await ctx.db.get(nodeIds[0]);
|
const firstExistingNode = await (async () => {
|
||||||
if (!firstNode) throw new Error("Node not found");
|
for (const nodeId of nodeIds) {
|
||||||
|
const node = await ctx.db.get(nodeId);
|
||||||
|
if (node) return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
if (!firstExistingNode) return;
|
||||||
|
|
||||||
|
// Canvas-Zugriff über den ersten vorhandenen Node prüfen
|
||||||
|
const firstNode = firstExistingNode;
|
||||||
await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId);
|
await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId);
|
||||||
|
|
||||||
for (const nodeId of nodeIds) {
|
for (const nodeId of nodeIds) {
|
||||||
|
|||||||
@@ -182,6 +182,99 @@ export function readCanvasOps(canvasId: string): CanvasPendingOp[] {
|
|||||||
return readOpsPayload(canvasId).ops;
|
return readOpsPayload(canvasId).ops;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function opTouchesNodeId(op: CanvasPendingOp, nodeIdSet: ReadonlySet<string>): boolean {
|
||||||
|
if (!isRecord(op.payload)) return false;
|
||||||
|
const payload = op.payload;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(typeof payload.nodeId === "string" && nodeIdSet.has(payload.nodeId)) ||
|
||||||
|
(typeof payload.sourceNodeId === "string" && nodeIdSet.has(payload.sourceNodeId)) ||
|
||||||
|
(typeof payload.targetNodeId === "string" && nodeIdSet.has(payload.targetNodeId)) ||
|
||||||
|
(typeof payload.parentId === "string" && nodeIdSet.has(payload.parentId))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.nodeIds)) {
|
||||||
|
return payload.nodeIds.some(
|
||||||
|
(entry) => typeof entry === "string" && nodeIdSet.has(entry),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.moves)) {
|
||||||
|
return payload.moves.some(
|
||||||
|
(move) =>
|
||||||
|
isRecord(move) &&
|
||||||
|
typeof move.nodeId === "string" &&
|
||||||
|
nodeIdSet.has(move.nodeId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opHasClientRequestId(
|
||||||
|
op: CanvasPendingOp,
|
||||||
|
clientRequestIdSet: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
if (!isRecord(op.payload)) return false;
|
||||||
|
return (
|
||||||
|
typeof op.payload.clientRequestId === "string" &&
|
||||||
|
clientRequestIdSet.has(op.payload.clientRequestId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function opTouchesEdgeId(op: CanvasPendingOp, edgeIdSet: ReadonlySet<string>): boolean {
|
||||||
|
if (!isRecord(op.payload)) return false;
|
||||||
|
return (
|
||||||
|
typeof op.payload.edgeId === "string" &&
|
||||||
|
edgeIdSet.has(op.payload.edgeId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropCanvasOpsByPredicate(
|
||||||
|
canvasId: string,
|
||||||
|
predicate: (op: CanvasPendingOp) => boolean,
|
||||||
|
): string[] {
|
||||||
|
const payload = readOpsPayload(canvasId);
|
||||||
|
const idsToDrop = payload.ops.filter(predicate).map((op) => op.id);
|
||||||
|
if (idsToDrop.length === 0) return [];
|
||||||
|
const idSet = new Set(idsToDrop);
|
||||||
|
payload.ops = payload.ops.filter((op) => !idSet.has(op.id));
|
||||||
|
payload.updatedAt = Date.now();
|
||||||
|
writePayload(opsKey(canvasId), payload);
|
||||||
|
return idsToDrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dropCanvasOpsByNodeIds(
|
||||||
|
canvasId: string,
|
||||||
|
nodeIds: string[],
|
||||||
|
): string[] {
|
||||||
|
if (nodeIds.length === 0) return [];
|
||||||
|
const nodeIdSet = new Set(nodeIds);
|
||||||
|
return dropCanvasOpsByPredicate(canvasId, (op) => opTouchesNodeId(op, nodeIdSet));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dropCanvasOpsByClientRequestIds(
|
||||||
|
canvasId: string,
|
||||||
|
clientRequestIds: string[],
|
||||||
|
): string[] {
|
||||||
|
if (clientRequestIds.length === 0) return [];
|
||||||
|
const clientRequestIdSet = new Set(clientRequestIds);
|
||||||
|
return dropCanvasOpsByPredicate(canvasId, (op) =>
|
||||||
|
opHasClientRequestId(op, clientRequestIdSet),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dropCanvasOpsByEdgeIds(
|
||||||
|
canvasId: string,
|
||||||
|
edgeIds: string[],
|
||||||
|
): string[] {
|
||||||
|
if (edgeIds.length === 0) return [];
|
||||||
|
const edgeIdSet = new Set(edgeIds);
|
||||||
|
return dropCanvasOpsByPredicate(canvasId, (op) => opTouchesEdgeId(op, edgeIdSet));
|
||||||
|
}
|
||||||
|
|
||||||
function remapNodeIdInPayload(
|
function remapNodeIdInPayload(
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
fromNodeId: string,
|
fromNodeId: string,
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export type CanvasSyncOpPayloadByType = {
|
|||||||
targetHandle?: string;
|
targetHandle?: string;
|
||||||
clientRequestId: string;
|
clientRequestId: string;
|
||||||
};
|
};
|
||||||
|
removeEdge: {
|
||||||
|
edgeId: Id<"edges">;
|
||||||
|
};
|
||||||
|
batchRemoveNodes: {
|
||||||
|
nodeIds: Id<"nodes">[];
|
||||||
|
};
|
||||||
moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number };
|
moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number };
|
||||||
resizeNode: { nodeId: Id<"nodes">; width: number; height: number };
|
resizeNode: { nodeId: Id<"nodes">; width: number; height: number };
|
||||||
updateData: { nodeId: Id<"nodes">; data: unknown };
|
updateData: { nodeId: Id<"nodes">; data: unknown };
|
||||||
@@ -210,6 +216,8 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
|||||||
type !== "createNodeWithEdgeFromSource" &&
|
type !== "createNodeWithEdgeFromSource" &&
|
||||||
type !== "createNodeWithEdgeToTarget" &&
|
type !== "createNodeWithEdgeToTarget" &&
|
||||||
type !== "createEdge" &&
|
type !== "createEdge" &&
|
||||||
|
type !== "removeEdge" &&
|
||||||
|
type !== "batchRemoveNodes" &&
|
||||||
type !== "moveNode" &&
|
type !== "moveNode" &&
|
||||||
type !== "resizeNode" &&
|
type !== "resizeNode" &&
|
||||||
type !== "updateData"
|
type !== "updateData"
|
||||||
@@ -393,6 +401,45 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === "removeEdge" &&
|
||||||
|
typeof payload.edgeId === "string"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
canvasId,
|
||||||
|
type,
|
||||||
|
payload: {
|
||||||
|
edgeId: payload.edgeId as Id<"edges">,
|
||||||
|
},
|
||||||
|
enqueuedAt,
|
||||||
|
attemptCount,
|
||||||
|
nextRetryAt,
|
||||||
|
expiresAt,
|
||||||
|
lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === "batchRemoveNodes" &&
|
||||||
|
Array.isArray(payload.nodeIds) &&
|
||||||
|
payload.nodeIds.every((entry) => typeof entry === "string")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
canvasId,
|
||||||
|
type,
|
||||||
|
payload: {
|
||||||
|
nodeIds: payload.nodeIds as Id<"nodes">[],
|
||||||
|
},
|
||||||
|
enqueuedAt,
|
||||||
|
attemptCount,
|
||||||
|
nextRetryAt,
|
||||||
|
expiresAt,
|
||||||
|
lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
type === "moveNode" &&
|
type === "moveNode" &&
|
||||||
typeof payload.nodeId === "string" &&
|
typeof payload.nodeId === "string" &&
|
||||||
@@ -713,6 +760,20 @@ function remapNodeIdInPayload(
|
|||||||
return { ...op, payload: next };
|
return { ...op, payload: next };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (op.type === "batchRemoveNodes") {
|
||||||
|
if (!op.payload.nodeIds.includes(fromNodeId as Id<"nodes">)) {
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...op,
|
||||||
|
payload: {
|
||||||
|
...op.payload,
|
||||||
|
nodeIds: op.payload.nodeIds.map((nodeId) =>
|
||||||
|
nodeId === fromNodeId ? (toNodeId as Id<"nodes">) : nodeId,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
return op;
|
return op;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,3 +808,114 @@ export async function remapCanvasSyncNodeId(
|
|||||||
await txDone(tx);
|
await txDone(tx);
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function opTouchesNodeId(op: CanvasSyncOp, nodeIdSet: ReadonlySet<string>): boolean {
|
||||||
|
if (op.type === "moveNode" || op.type === "resizeNode" || op.type === "updateData") {
|
||||||
|
return nodeIdSet.has(op.payload.nodeId);
|
||||||
|
}
|
||||||
|
if (op.type === "createEdge") {
|
||||||
|
return (
|
||||||
|
nodeIdSet.has(op.payload.sourceNodeId) || nodeIdSet.has(op.payload.targetNodeId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (op.type === "createNode") {
|
||||||
|
return op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId);
|
||||||
|
}
|
||||||
|
if (op.type === "createNodeWithEdgeFromSource") {
|
||||||
|
return (
|
||||||
|
nodeIdSet.has(op.payload.sourceNodeId) ||
|
||||||
|
(op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (op.type === "createNodeWithEdgeToTarget") {
|
||||||
|
return (
|
||||||
|
nodeIdSet.has(op.payload.targetNodeId) ||
|
||||||
|
(op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (op.type === "batchRemoveNodes") {
|
||||||
|
return op.payload.nodeIds.some((nodeId) => nodeIdSet.has(nodeId));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opHasClientRequestId(op: CanvasSyncOp, clientRequestIdSet: ReadonlySet<string>): boolean {
|
||||||
|
if (op.type === "createNode") {
|
||||||
|
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||||
|
}
|
||||||
|
if (op.type === "createNodeWithEdgeFromSource") {
|
||||||
|
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||||
|
}
|
||||||
|
if (op.type === "createNodeWithEdgeToTarget") {
|
||||||
|
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||||
|
}
|
||||||
|
if (op.type === "createEdge") {
|
||||||
|
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opTouchesEdgeId(op: CanvasSyncOp, edgeIdSet: ReadonlySet<string>): boolean {
|
||||||
|
if (op.type === "removeEdge") {
|
||||||
|
return edgeIdSet.has(op.payload.edgeId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropCanvasSyncOpsByPredicate(
|
||||||
|
canvasId: string,
|
||||||
|
predicate: (op: CanvasSyncOp) => boolean,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const all = await listCanvasSyncOps(canvasId);
|
||||||
|
const idsToDrop = all.filter(predicate).map((entry) => entry.id);
|
||||||
|
if (idsToDrop.length === 0) return [];
|
||||||
|
|
||||||
|
const idSet = new Set(idsToDrop);
|
||||||
|
const db = await openDb();
|
||||||
|
if (!db) {
|
||||||
|
const fallback = readFallbackOps().filter((entry) => !idSet.has(entry.id));
|
||||||
|
writeFallbackOps(fallback);
|
||||||
|
return idsToDrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
for (const id of idsToDrop) {
|
||||||
|
store.delete(id);
|
||||||
|
}
|
||||||
|
await txDone(tx);
|
||||||
|
return idsToDrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropCanvasSyncOpsByNodeIds(
|
||||||
|
canvasId: string,
|
||||||
|
nodeIds: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (nodeIds.length === 0) return [];
|
||||||
|
const nodeIdSet = new Set(nodeIds);
|
||||||
|
return await dropCanvasSyncOpsByPredicate(canvasId, (op) =>
|
||||||
|
opTouchesNodeId(op, nodeIdSet),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropCanvasSyncOpsByClientRequestIds(
|
||||||
|
canvasId: string,
|
||||||
|
clientRequestIds: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (clientRequestIds.length === 0) return [];
|
||||||
|
const idSet = new Set(clientRequestIds);
|
||||||
|
return await dropCanvasSyncOpsByPredicate(canvasId, (op) =>
|
||||||
|
opHasClientRequestId(op, idSet),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropCanvasSyncOpsByEdgeIds(
|
||||||
|
canvasId: string,
|
||||||
|
edgeIds: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (edgeIds.length === 0) return [];
|
||||||
|
const edgeIdSet = new Set(edgeIds);
|
||||||
|
return await dropCanvasSyncOpsByPredicate(canvasId, (op) =>
|
||||||
|
opTouchesEdgeId(op, edgeIdSet),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user