Enable offline edge split sync and stabilize local edge state
This commit is contained in:
@@ -122,10 +122,10 @@ Compare-Node hat zusätzlich Handle-spezifische Farben (`left` → Blau, `right`
|
|||||||
|
|
||||||
- Key-Schema: `lemonspace.canvas:snapshot:v1:<canvasId>` und `lemonspace.canvas:ops:v1:<canvasId>`
|
- Key-Schema: `lemonspace.canvas:snapshot:v1:<canvasId>` und `lemonspace.canvas:ops:v1:<canvasId>`
|
||||||
- Snapshot = letzter bekannter State (Nodes + Edges) für schnellen initialen Render
|
- Snapshot = letzter bekannter State (Nodes + Edges) für schnellen initialen Render
|
||||||
- Ops-Queue ist aktiv für: `createNode*`, `createEdge`, `moveNode`, `resizeNode`, `updateData`, `removeEdge`, `batchRemoveNodes`.
|
- Ops-Queue ist aktiv für: `createNode*` (inkl. `createNodeWithEdgeSplit`), `createEdge`, `splitEdgeAtExistingNode`, `moveNode`, `resizeNode`, `updateData`, `removeEdge`, `batchRemoveNodes`.
|
||||||
- Reconnect synchronisiert als `createEdge + removeEdge` (statt rein lokalem UI-Umbiegen).
|
- Reconnect synchronisiert als `createEdge + removeEdge` (statt rein lokalem UI-Umbiegen).
|
||||||
- ID-Handover `optimistic_* → realId` remappt Folge-Operationen in Queue + localStorage-Mirror, damit Verbindungen während/ nach Replay stabil bleiben.
|
- ID-Handover `optimistic_* → realId` remappt Folge-Operationen in Queue + localStorage-Mirror, damit Verbindungen während/ nach Replay stabil bleiben.
|
||||||
- Unsupported offline (weiterhin online-only): `createWithEdgeSplit`, Datei-Upload/Storage-Mutations, AI-Generierung.
|
- Unsupported offline (weiterhin online-only): Datei-Upload/Storage-Mutations, AI-Generierung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -366,6 +366,43 @@ function isBatchMoveNodesOpPayload(
|
|||||||
return record.moves.every(isMoveNodeOpPayload);
|
return record.moves.every(isMoveNodeOpPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSplitEdgeAtExistingNodeOpPayload(
|
||||||
|
payload: unknown,
|
||||||
|
): payload is {
|
||||||
|
middleNodeId: Id<"nodes">;
|
||||||
|
positionX?: number;
|
||||||
|
positionY?: number;
|
||||||
|
} {
|
||||||
|
if (typeof payload !== "object" || payload === null) return false;
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (typeof record.middleNodeId !== "string") return false;
|
||||||
|
const hasPositionX =
|
||||||
|
record.positionX === undefined || typeof record.positionX === "number";
|
||||||
|
const hasPositionY =
|
||||||
|
record.positionY === undefined || typeof record.positionY === "number";
|
||||||
|
return hasPositionX && hasPositionY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemoveEdgeOpPayload(
|
||||||
|
payload: unknown,
|
||||||
|
): payload is {
|
||||||
|
edgeId: Id<"edges">;
|
||||||
|
} {
|
||||||
|
if (typeof payload !== "object" || payload === null) return false;
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
return typeof record.edgeId === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSplitEdgeOpPayload(
|
||||||
|
payload: unknown,
|
||||||
|
): payload is {
|
||||||
|
splitEdgeId: Id<"edges">;
|
||||||
|
} {
|
||||||
|
if (typeof payload !== "object" || payload === null) return false;
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
return typeof record.splitEdgeId === "string";
|
||||||
|
}
|
||||||
|
|
||||||
export function getPendingMovePinsFromLocalOps(
|
export function getPendingMovePinsFromLocalOps(
|
||||||
canvasId: string,
|
canvasId: string,
|
||||||
): Map<string, { x: number; y: number }> {
|
): Map<string, { x: number; y: number }> {
|
||||||
@@ -385,11 +422,43 @@ export function getPendingMovePinsFromLocalOps(
|
|||||||
y: move.positionY,
|
y: move.positionY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
op.type === "splitEdgeAtExistingNode" &&
|
||||||
|
isSplitEdgeAtExistingNodeOpPayload(op.payload) &&
|
||||||
|
op.payload.positionX !== undefined &&
|
||||||
|
op.payload.positionY !== undefined
|
||||||
|
) {
|
||||||
|
pins.set(op.payload.middleNodeId as string, {
|
||||||
|
x: op.payload.positionX,
|
||||||
|
y: op.payload.positionY,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return pins;
|
return pins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPendingRemovedEdgeIdsFromLocalOps(
|
||||||
|
canvasId: string,
|
||||||
|
): Set<string> {
|
||||||
|
const edgeIds = new Set<string>();
|
||||||
|
for (const op of readCanvasOps(canvasId)) {
|
||||||
|
if (op.type === "removeEdge" && isRemoveEdgeOpPayload(op.payload)) {
|
||||||
|
edgeIds.add(op.payload.edgeId as string);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(op.type === "createNodeWithEdgeSplit" ||
|
||||||
|
op.type === "splitEdgeAtExistingNode") &&
|
||||||
|
isSplitEdgeOpPayload(op.payload)
|
||||||
|
) {
|
||||||
|
edgeIds.add(op.payload.splitEdgeId as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edgeIds;
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeNodesPreservingLocalState(
|
export function mergeNodesPreservingLocalState(
|
||||||
previousNodes: RFNode[],
|
previousNodes: RFNode[],
|
||||||
incomingNodes: RFNode[],
|
incomingNodes: RFNode[],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useStore, type Edge as RFEdge } from "@xyflow/react";
|
|||||||
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
|
import { isOptimisticEdgeId } from "./canvas-helpers";
|
||||||
|
|
||||||
type CreateNodeArgs = {
|
type CreateNodeArgs = {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -40,6 +41,7 @@ type CreateNodeWithEdgeSplitArgs = {
|
|||||||
newNodeSourceHandle?: string;
|
newNodeSourceHandle?: string;
|
||||||
splitSourceHandle?: string;
|
splitSourceHandle?: string;
|
||||||
splitTargetHandle?: string;
|
splitTargetHandle?: string;
|
||||||
|
clientRequestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateNodeWithEdgeFromSourceArgs = CreateNodeArgs & {
|
type CreateNodeWithEdgeFromSourceArgs = CreateNodeArgs & {
|
||||||
@@ -143,7 +145,9 @@ function getIntersectedPersistedEdge(
|
|||||||
if (!edgeId) return undefined;
|
if (!edgeId) return undefined;
|
||||||
|
|
||||||
const edge = edges.find((candidate) => candidate.id === edgeId);
|
const edge = edges.find((candidate) => candidate.id === edgeId);
|
||||||
if (!edge || edge.className === "temp") return undefined;
|
if (!edge || edge.className === "temp" || isOptimisticEdgeId(edge.id)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return edge;
|
return edge;
|
||||||
}
|
}
|
||||||
@@ -253,16 +257,11 @@ export function CanvasPlacementProvider({
|
|||||||
newNodeSourceHandle: normalizeHandle(handles.source),
|
newNodeSourceHandle: normalizeHandle(handles.source),
|
||||||
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
|
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
|
||||||
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
|
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
|
||||||
|
...(clientRequestId !== undefined ? { clientRequestId } : {}),
|
||||||
});
|
});
|
||||||
notifySettled(realId);
|
notifySettled(realId);
|
||||||
return realId;
|
return realId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
|
||||||
error instanceof Error &&
|
|
||||||
error.message === "offline-unsupported"
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
console.error("[Canvas placement] edge split failed", {
|
console.error("[Canvas placement] edge split failed", {
|
||||||
edgeId: hitEdge.id,
|
edgeId: hitEdge.id,
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ import {
|
|||||||
getMiniMapNodeStrokeColor,
|
getMiniMapNodeStrokeColor,
|
||||||
getNodeCenterClientPosition,
|
getNodeCenterClientPosition,
|
||||||
getIntersectedEdgeId,
|
getIntersectedEdgeId,
|
||||||
|
getPendingRemovedEdgeIdsFromLocalOps,
|
||||||
getPendingMovePinsFromLocalOps,
|
getPendingMovePinsFromLocalOps,
|
||||||
hasHandleKey,
|
hasHandleKey,
|
||||||
inferPendingConnectionNodeHandoff,
|
inferPendingConnectionNodeHandoff,
|
||||||
@@ -408,7 +409,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
|
const createNodeWithEdgeSplitMut = useMutation(api.nodes.createWithEdgeSplit);
|
||||||
|
|
||||||
const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
|
const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
|
||||||
(localStore, args) => {
|
(localStore, args) => {
|
||||||
@@ -445,12 +446,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const createNodeWithEdgeToTargetRaw = useMutation(
|
const createNodeWithEdgeToTargetRaw = useMutation(
|
||||||
api.nodes.createWithEdgeToTarget,
|
api.nodes.createWithEdgeToTarget,
|
||||||
);
|
);
|
||||||
|
const createNodeWithEdgeSplitRaw = useMutation(api.nodes.createWithEdgeSplit);
|
||||||
const createEdgeRaw = useMutation(api.edges.create);
|
const createEdgeRaw = useMutation(api.edges.create);
|
||||||
const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove);
|
const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove);
|
||||||
const removeEdgeRaw = useMutation(api.edges.remove);
|
const removeEdgeRaw = useMutation(api.edges.remove);
|
||||||
|
const splitEdgeAtExistingNodeRaw = useMutation(
|
||||||
|
api.nodes.splitEdgeAtExistingNode,
|
||||||
|
);
|
||||||
|
|
||||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||||
|
const edgesRef = useRef(edges);
|
||||||
|
edgesRef.current = edges;
|
||||||
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
||||||
@@ -534,6 +541,84 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
return optimisticEdgeId as Id<"edges">;
|
return optimisticEdgeId as Id<"edges">;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const applyEdgeSplitLocally = useCallback((args: {
|
||||||
|
clientRequestId: string;
|
||||||
|
splitEdgeId: Id<"edges">;
|
||||||
|
middleNodeId: Id<"nodes">;
|
||||||
|
splitSourceHandle?: string;
|
||||||
|
splitTargetHandle?: string;
|
||||||
|
newNodeSourceHandle?: string;
|
||||||
|
newNodeTargetHandle?: string;
|
||||||
|
positionX?: number;
|
||||||
|
positionY?: number;
|
||||||
|
}): boolean => {
|
||||||
|
const splitEdgeId = args.splitEdgeId as string;
|
||||||
|
const splitEdge = edgesRef.current.find(
|
||||||
|
(edge) =>
|
||||||
|
edge.id === splitEdgeId &&
|
||||||
|
edge.className !== "temp" &&
|
||||||
|
!isOptimisticEdgeId(edge.id),
|
||||||
|
);
|
||||||
|
if (!splitEdge) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimisticSplitEdgeBase = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`;
|
||||||
|
const optimisticSplitEdgeAId = `${optimisticSplitEdgeBase}_split_a`;
|
||||||
|
const optimisticSplitEdgeBId = `${optimisticSplitEdgeBase}_split_b`;
|
||||||
|
|
||||||
|
setEdges((current) => {
|
||||||
|
const existingSplitEdge = current.find((edge) => edge.id === splitEdgeId);
|
||||||
|
if (!existingSplitEdge) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = current.filter(
|
||||||
|
(edge) =>
|
||||||
|
edge.id !== splitEdgeId &&
|
||||||
|
edge.id !== optimisticSplitEdgeAId &&
|
||||||
|
edge.id !== optimisticSplitEdgeBId,
|
||||||
|
);
|
||||||
|
|
||||||
|
next.push(
|
||||||
|
{
|
||||||
|
id: optimisticSplitEdgeAId,
|
||||||
|
source: existingSplitEdge.source,
|
||||||
|
target: args.middleNodeId as string,
|
||||||
|
sourceHandle: args.splitSourceHandle,
|
||||||
|
targetHandle: args.newNodeTargetHandle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: optimisticSplitEdgeBId,
|
||||||
|
source: args.middleNodeId as string,
|
||||||
|
target: existingSplitEdge.target,
|
||||||
|
sourceHandle: args.newNodeSourceHandle,
|
||||||
|
targetHandle: args.splitTargetHandle,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.positionX !== undefined && args.positionY !== undefined) {
|
||||||
|
const x = args.positionX;
|
||||||
|
const y = args.positionY;
|
||||||
|
const middleNodeId = args.middleNodeId as string;
|
||||||
|
setNodes((current) =>
|
||||||
|
current.map((node) =>
|
||||||
|
node.id === middleNodeId
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
position: { x, y },
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const removeOptimisticCreateLocally = useCallback((args: {
|
const removeOptimisticCreateLocally = useCallback((args: {
|
||||||
clientRequestId: string;
|
clientRequestId: string;
|
||||||
removeNode?: boolean;
|
removeNode?: boolean;
|
||||||
@@ -555,8 +640,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.removeEdge) {
|
if (args.removeEdge) {
|
||||||
|
const optimisticEdgePrefix = `${optimisticEdgeId}_`;
|
||||||
setEdges((current) =>
|
setEdges((current) =>
|
||||||
current.filter((edge) => edge.id !== optimisticEdgeId),
|
current.filter(
|
||||||
|
(edge) =>
|
||||||
|
edge.id !== optimisticEdgeId &&
|
||||||
|
!edge.id.startsWith(optimisticEdgePrefix),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,14 +792,47 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runCreateNodeWithEdgeSplitOnlineOnly = useCallback(
|
const runCreateNodeWithEdgeSplitOnlineOnly = useCallback(
|
||||||
async (args: Parameters<typeof createNodeWithEdgeSplit>[0]) => {
|
async (args: Parameters<typeof createNodeWithEdgeSplitMut>[0]) => {
|
||||||
if (!isSyncOnline) {
|
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||||
notifyOfflineUnsupported("Kanten-Split");
|
const payload = { ...args, clientRequestId };
|
||||||
throw new Error("offline-unsupported");
|
|
||||||
|
if (isSyncOnline) {
|
||||||
|
return await createNodeWithEdgeSplitMut(payload);
|
||||||
}
|
}
|
||||||
return await createNodeWithEdgeSplit(args);
|
|
||||||
|
const optimisticNodeId = addOptimisticNodeLocally(payload);
|
||||||
|
const splitApplied = applyEdgeSplitLocally({
|
||||||
|
clientRequestId,
|
||||||
|
splitEdgeId: payload.splitEdgeId,
|
||||||
|
middleNodeId: optimisticNodeId,
|
||||||
|
splitSourceHandle: payload.splitSourceHandle,
|
||||||
|
splitTargetHandle: payload.splitTargetHandle,
|
||||||
|
newNodeSourceHandle: payload.newNodeSourceHandle,
|
||||||
|
newNodeTargetHandle: payload.newNodeTargetHandle,
|
||||||
|
positionX: payload.positionX,
|
||||||
|
positionY: payload.positionY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (splitApplied) {
|
||||||
|
await enqueueSyncMutationRef.current("createNodeWithEdgeSplit", payload);
|
||||||
|
} else {
|
||||||
|
await enqueueSyncMutationRef.current("createNode", {
|
||||||
|
canvasId: payload.canvasId,
|
||||||
|
type: payload.type,
|
||||||
|
positionX: payload.positionX,
|
||||||
|
positionY: payload.positionY,
|
||||||
|
width: payload.width,
|
||||||
|
height: payload.height,
|
||||||
|
data: payload.data,
|
||||||
|
parentId: payload.parentId,
|
||||||
|
zIndex: payload.zIndex,
|
||||||
|
clientRequestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return optimisticNodeId;
|
||||||
},
|
},
|
||||||
[createNodeWithEdgeSplit, isSyncOnline, notifyOfflineUnsupported],
|
[addOptimisticNodeLocally, applyEdgeSplitLocally, createNodeWithEdgeSplitMut, isSyncOnline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshPendingSyncCount = useCallback(async () => {
|
const refreshPendingSyncCount = useCallback(async () => {
|
||||||
@@ -771,12 +894,23 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
realId,
|
realId,
|
||||||
);
|
);
|
||||||
setEdgeSyncNonce((value) => value + 1);
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
|
} else if (op.type === "createNodeWithEdgeSplit") {
|
||||||
|
const realId = await createNodeWithEdgeSplitRaw(op.payload);
|
||||||
|
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
||||||
|
await syncPendingMoveForClientRequestRef.current(
|
||||||
|
op.payload.clientRequestId,
|
||||||
|
realId,
|
||||||
|
);
|
||||||
|
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") {
|
} else if (op.type === "removeEdge") {
|
||||||
await removeEdgeRaw(op.payload);
|
await removeEdgeRaw(op.payload);
|
||||||
} else if (op.type === "batchRemoveNodes") {
|
} else if (op.type === "batchRemoveNodes") {
|
||||||
await batchRemoveNodesRaw(op.payload);
|
await batchRemoveNodesRaw(op.payload);
|
||||||
|
} else if (op.type === "splitEdgeAtExistingNode") {
|
||||||
|
await splitEdgeAtExistingNodeRaw(op.payload);
|
||||||
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
} 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") {
|
||||||
@@ -814,11 +948,24 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
removeNode: true,
|
removeNode: true,
|
||||||
removeEdge: true,
|
removeEdge: true,
|
||||||
});
|
});
|
||||||
|
} else if (op.type === "createNodeWithEdgeSplit") {
|
||||||
|
removeOptimisticCreateLocally({
|
||||||
|
clientRequestId: op.payload.clientRequestId,
|
||||||
|
removeNode: true,
|
||||||
|
removeEdge: true,
|
||||||
|
});
|
||||||
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
} else if (op.type === "createEdge") {
|
} else if (op.type === "createEdge") {
|
||||||
removeOptimisticCreateLocally({
|
removeOptimisticCreateLocally({
|
||||||
clientRequestId: op.payload.clientRequestId,
|
clientRequestId: op.payload.clientRequestId,
|
||||||
removeEdge: true,
|
removeEdge: true,
|
||||||
});
|
});
|
||||||
|
} else if (op.type === "splitEdgeAtExistingNode") {
|
||||||
|
removeOptimisticCreateLocally({
|
||||||
|
clientRequestId: op.payload.clientRequestId,
|
||||||
|
removeEdge: true,
|
||||||
|
});
|
||||||
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
}
|
}
|
||||||
await ackCanvasSyncOp(op.id);
|
await ackCanvasSyncOp(op.id);
|
||||||
resolveCanvasOp(canvasId as string, op.id);
|
resolveCanvasOp(canvasId as string, op.id);
|
||||||
@@ -842,6 +989,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
createEdgeRaw,
|
createEdgeRaw,
|
||||||
createNodeRaw,
|
createNodeRaw,
|
||||||
createNodeWithEdgeFromSourceRaw,
|
createNodeWithEdgeFromSourceRaw,
|
||||||
|
createNodeWithEdgeSplitRaw,
|
||||||
createNodeWithEdgeToTargetRaw,
|
createNodeWithEdgeToTargetRaw,
|
||||||
isSyncOnline,
|
isSyncOnline,
|
||||||
moveNode,
|
moveNode,
|
||||||
@@ -850,6 +998,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
removeEdgeRaw,
|
removeEdgeRaw,
|
||||||
removeOptimisticCreateLocally,
|
removeOptimisticCreateLocally,
|
||||||
resizeNode,
|
resizeNode,
|
||||||
|
splitEdgeAtExistingNodeRaw,
|
||||||
updateNodeData,
|
updateNodeData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1124,13 +1273,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
const runSplitEdgeAtExistingNodeMutation = useCallback(
|
const runSplitEdgeAtExistingNodeMutation = useCallback(
|
||||||
async (args: Parameters<typeof splitEdgeAtExistingNodeMut>[0]) => {
|
async (args: Parameters<typeof splitEdgeAtExistingNodeMut>[0]) => {
|
||||||
if (!isSyncOnline) {
|
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||||
notifyOfflineUnsupported("Kanten-Split");
|
const payload = { ...args, clientRequestId };
|
||||||
|
if (isSyncOnline) {
|
||||||
|
await splitEdgeAtExistingNodeMut(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await splitEdgeAtExistingNodeMut(args);
|
|
||||||
|
const splitApplied = applyEdgeSplitLocally({
|
||||||
|
clientRequestId,
|
||||||
|
splitEdgeId: payload.splitEdgeId,
|
||||||
|
middleNodeId: payload.middleNodeId,
|
||||||
|
splitSourceHandle: payload.splitSourceHandle,
|
||||||
|
splitTargetHandle: payload.splitTargetHandle,
|
||||||
|
newNodeSourceHandle: payload.newNodeSourceHandle,
|
||||||
|
newNodeTargetHandle: payload.newNodeTargetHandle,
|
||||||
|
positionX: payload.positionX,
|
||||||
|
positionY: payload.positionY,
|
||||||
|
});
|
||||||
|
if (!splitApplied) return;
|
||||||
|
|
||||||
|
await enqueueSyncMutation("splitEdgeAtExistingNode", payload);
|
||||||
},
|
},
|
||||||
[isSyncOnline, notifyOfflineUnsupported, splitEdgeAtExistingNodeMut],
|
[
|
||||||
|
applyEdgeSplitLocally,
|
||||||
|
enqueueSyncMutation,
|
||||||
|
isSyncOnline,
|
||||||
|
splitEdgeAtExistingNodeMut,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
|
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
|
||||||
@@ -1334,8 +1504,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
|
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
|
||||||
}, [scissorsMode, navTool]);
|
}, [scissorsMode, navTool]);
|
||||||
|
|
||||||
const edgesRef = useRef(edges);
|
|
||||||
edgesRef.current = edges;
|
|
||||||
const scissorsModeRef = useRef(scissorsMode);
|
const scissorsModeRef = useRef(scissorsMode);
|
||||||
scissorsModeRef.current = scissorsMode;
|
scissorsModeRef.current = scissorsMode;
|
||||||
|
|
||||||
@@ -1408,6 +1576,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tempEdges = prev.filter((e) => e.className === "temp");
|
const tempEdges = prev.filter((e) => e.className === "temp");
|
||||||
|
const pendingRemovedEdgeIds = getPendingRemovedEdgeIdsFromLocalOps(
|
||||||
|
canvasId as string,
|
||||||
|
);
|
||||||
const sourceTypeByNodeId =
|
const sourceTypeByNodeId =
|
||||||
convexNodes !== undefined
|
convexNodes !== undefined
|
||||||
? new Map<string, string>(
|
? new Map<string, string>(
|
||||||
@@ -1415,7 +1586,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const glowMode = resolvedTheme === "dark" ? "dark" : "light";
|
const glowMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||||
const mapped = convexEdges.map((edge: Doc<"edges">) =>
|
const mapped = convexEdges
|
||||||
|
.filter((edge: Doc<"edges">) => !pendingRemovedEdgeIds.has(edge._id as string))
|
||||||
|
.map((edge: Doc<"edges">) =>
|
||||||
sourceTypeByNodeId
|
sourceTypeByNodeId
|
||||||
? convexEdgeToRFWithSourceGlow(
|
? convexEdgeToRFWithSourceGlow(
|
||||||
edge,
|
edge,
|
||||||
@@ -1562,7 +1735,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
return [...mapped, ...carriedOptimistic, ...tempEdges];
|
return [...mapped, ...carriedOptimistic, ...tempEdges];
|
||||||
});
|
});
|
||||||
}, [convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
|
}, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!convexNodes || isResizing.current) return;
|
if (!convexNodes || isResizing.current) return;
|
||||||
@@ -1776,7 +1949,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const intersectedEdge = edges.find(
|
const intersectedEdge = edges.find(
|
||||||
(edge) => edge.id === intersectedEdgeId && edge.className !== "temp",
|
(edge) =>
|
||||||
|
edge.id === intersectedEdgeId &&
|
||||||
|
edge.className !== "temp" &&
|
||||||
|
!isOptimisticEdgeId(edge.id),
|
||||||
);
|
);
|
||||||
if (!intersectedEdge) {
|
if (!intersectedEdge) {
|
||||||
overlappedEdgeRef.current = null;
|
overlappedEdgeRef.current = null;
|
||||||
@@ -1836,7 +2012,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const intersectedEdge = intersectedEdgeId
|
const intersectedEdge = intersectedEdgeId
|
||||||
? edges.find(
|
? edges.find(
|
||||||
(edge) =>
|
(edge) =>
|
||||||
edge.id === intersectedEdgeId && edge.className !== "temp",
|
edge.id === intersectedEdgeId &&
|
||||||
|
edge.className !== "temp" &&
|
||||||
|
!isOptimisticEdgeId(edge.id),
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -123,8 +123,9 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu
|
|||||||
|
|
||||||
### Idempotente Canvas-Mutations
|
### Idempotente Canvas-Mutations
|
||||||
|
|
||||||
- `nodes.create`, `nodes.createWithEdgeFromSource`, `nodes.createWithEdgeToTarget` sind über `clientRequestId` idempotent.
|
- `nodes.create`, `nodes.createWithEdgeSplit`, `nodes.createWithEdgeFromSource`, `nodes.createWithEdgeToTarget` sind über `clientRequestId` idempotent.
|
||||||
- `edges.create` ist über `clientRequestId` idempotent.
|
- `edges.create` ist über `clientRequestId` idempotent.
|
||||||
|
- `nodes.splitEdgeAtExistingNode` ist über `clientRequestId` idempotent (Replay wird als No-op behandelt).
|
||||||
- `nodes.batchRemove` ist idempotent tolerant: wenn alle angefragten Nodes bereits entfernt sind, wird die Mutation als No-op beendet.
|
- `nodes.batchRemove` ist idempotent tolerant: wenn alle angefragten Nodes bereits entfernt sind, wird die Mutation als No-op beendet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async function getCanvasIfAuthorized(
|
|||||||
|
|
||||||
type NodeCreateMutationName =
|
type NodeCreateMutationName =
|
||||||
| "nodes.create"
|
| "nodes.create"
|
||||||
|
| "nodes.createWithEdgeSplit"
|
||||||
| "nodes.createWithEdgeFromSource"
|
| "nodes.createWithEdgeFromSource"
|
||||||
| "nodes.createWithEdgeToTarget";
|
| "nodes.createWithEdgeToTarget";
|
||||||
|
|
||||||
@@ -248,11 +249,22 @@ export const createWithEdgeSplit = mutation({
|
|||||||
newNodeSourceHandle: v.optional(v.string()),
|
newNodeSourceHandle: v.optional(v.string()),
|
||||||
splitSourceHandle: v.optional(v.string()),
|
splitSourceHandle: v.optional(v.string()),
|
||||||
splitTargetHandle: v.optional(v.string()),
|
splitTargetHandle: v.optional(v.string()),
|
||||||
|
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);
|
||||||
|
|
||||||
|
const existingNodeId = await getIdempotentNodeCreateResult(ctx, {
|
||||||
|
userId: user.userId,
|
||||||
|
mutation: "nodes.createWithEdgeSplit",
|
||||||
|
clientRequestId: args.clientRequestId,
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
});
|
||||||
|
if (existingNodeId) {
|
||||||
|
return existingNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
const edge = await ctx.db.get(args.splitEdgeId);
|
const edge = await ctx.db.get(args.splitEdgeId);
|
||||||
if (!edge || edge.canvasId !== args.canvasId) {
|
if (!edge || edge.canvasId !== args.canvasId) {
|
||||||
throw new Error("Edge not found");
|
throw new Error("Edge not found");
|
||||||
@@ -290,6 +302,13 @@ export const createWithEdgeSplit = mutation({
|
|||||||
|
|
||||||
await ctx.db.delete(args.splitEdgeId);
|
await ctx.db.delete(args.splitEdgeId);
|
||||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||||
|
await rememberIdempotentNodeCreateResult(ctx, {
|
||||||
|
userId: user.userId,
|
||||||
|
mutation: "nodes.createWithEdgeSplit",
|
||||||
|
clientRequestId: args.clientRequestId,
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
nodeId,
|
||||||
|
});
|
||||||
|
|
||||||
return nodeId;
|
return nodeId;
|
||||||
},
|
},
|
||||||
@@ -310,11 +329,34 @@ export const splitEdgeAtExistingNode = mutation({
|
|||||||
newNodeTargetHandle: v.optional(v.string()),
|
newNodeTargetHandle: v.optional(v.string()),
|
||||||
positionX: v.optional(v.number()),
|
positionX: v.optional(v.number()),
|
||||||
positionY: v.optional(v.number()),
|
positionY: v.optional(v.number()),
|
||||||
|
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);
|
||||||
|
|
||||||
|
const existingMutationRecord =
|
||||||
|
args.clientRequestId === undefined
|
||||||
|
? null
|
||||||
|
: await ctx.db
|
||||||
|
.query("mutationRequests")
|
||||||
|
.withIndex("by_user_mutation_request", (q) =>
|
||||||
|
q
|
||||||
|
.eq("userId", user.userId)
|
||||||
|
.eq("mutation", "nodes.splitEdgeAtExistingNode")
|
||||||
|
.eq("clientRequestId", args.clientRequestId!),
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
if (existingMutationRecord) {
|
||||||
|
if (
|
||||||
|
existingMutationRecord.canvasId &&
|
||||||
|
existingMutationRecord.canvasId !== args.canvasId
|
||||||
|
) {
|
||||||
|
throw new Error("Client request conflict");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const edge = await ctx.db.get(args.splitEdgeId);
|
const edge = await ctx.db.get(args.splitEdgeId);
|
||||||
if (!edge || edge.canvasId !== args.canvasId) {
|
if (!edge || edge.canvasId !== args.canvasId) {
|
||||||
throw new Error("Edge not found");
|
throw new Error("Edge not found");
|
||||||
@@ -360,6 +402,18 @@ export const splitEdgeAtExistingNode = mutation({
|
|||||||
|
|
||||||
await ctx.db.delete(args.splitEdgeId);
|
await ctx.db.delete(args.splitEdgeId);
|
||||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||||
|
|
||||||
|
if (args.clientRequestId) {
|
||||||
|
await ctx.db.insert("mutationRequests", {
|
||||||
|
userId: user.userId,
|
||||||
|
mutation: "nodes.splitEdgeAtExistingNode",
|
||||||
|
clientRequestId: args.clientRequestId,
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
nodeId: args.middleNodeId,
|
||||||
|
edgeId: args.splitEdgeId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,8 @@ function opTouchesNodeId(op: CanvasPendingOp, nodeIdSet: ReadonlySet<string>): b
|
|||||||
(typeof payload.nodeId === "string" && nodeIdSet.has(payload.nodeId)) ||
|
(typeof payload.nodeId === "string" && nodeIdSet.has(payload.nodeId)) ||
|
||||||
(typeof payload.sourceNodeId === "string" && nodeIdSet.has(payload.sourceNodeId)) ||
|
(typeof payload.sourceNodeId === "string" && nodeIdSet.has(payload.sourceNodeId)) ||
|
||||||
(typeof payload.targetNodeId === "string" && nodeIdSet.has(payload.targetNodeId)) ||
|
(typeof payload.targetNodeId === "string" && nodeIdSet.has(payload.targetNodeId)) ||
|
||||||
(typeof payload.parentId === "string" && nodeIdSet.has(payload.parentId))
|
(typeof payload.parentId === "string" && nodeIdSet.has(payload.parentId)) ||
|
||||||
|
(typeof payload.middleNodeId === "string" && nodeIdSet.has(payload.middleNodeId))
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -227,8 +228,10 @@ function opHasClientRequestId(
|
|||||||
function opTouchesEdgeId(op: CanvasPendingOp, edgeIdSet: ReadonlySet<string>): boolean {
|
function opTouchesEdgeId(op: CanvasPendingOp, edgeIdSet: ReadonlySet<string>): boolean {
|
||||||
if (!isRecord(op.payload)) return false;
|
if (!isRecord(op.payload)) return false;
|
||||||
return (
|
return (
|
||||||
typeof op.payload.edgeId === "string" &&
|
(typeof op.payload.edgeId === "string" &&
|
||||||
edgeIdSet.has(op.payload.edgeId)
|
edgeIdSet.has(op.payload.edgeId)) ||
|
||||||
|
(typeof op.payload.splitEdgeId === "string" &&
|
||||||
|
edgeIdSet.has(op.payload.splitEdgeId))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +294,10 @@ function remapNodeIdInPayload(
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (nextPayload.middleNodeId === fromNodeId) {
|
||||||
|
nextPayload.middleNodeId = toNodeId;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
const moves = nextPayload.moves;
|
const moves = nextPayload.moves;
|
||||||
if (Array.isArray(moves)) {
|
if (Array.isArray(moves)) {
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ export type CanvasSyncOpPayloadByType = {
|
|||||||
sourceHandle?: string;
|
sourceHandle?: string;
|
||||||
targetHandle?: string;
|
targetHandle?: string;
|
||||||
};
|
};
|
||||||
|
createNodeWithEdgeSplit: {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
type: string;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: unknown;
|
||||||
|
parentId?: Id<"nodes">;
|
||||||
|
zIndex?: number;
|
||||||
|
splitEdgeId: Id<"edges">;
|
||||||
|
newNodeTargetHandle?: string;
|
||||||
|
newNodeSourceHandle?: string;
|
||||||
|
splitSourceHandle?: string;
|
||||||
|
splitTargetHandle?: string;
|
||||||
|
clientRequestId: string;
|
||||||
|
};
|
||||||
createEdge: {
|
createEdge: {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
sourceNodeId: Id<"nodes">;
|
sourceNodeId: Id<"nodes">;
|
||||||
@@ -63,6 +80,18 @@ export type CanvasSyncOpPayloadByType = {
|
|||||||
batchRemoveNodes: {
|
batchRemoveNodes: {
|
||||||
nodeIds: Id<"nodes">[];
|
nodeIds: Id<"nodes">[];
|
||||||
};
|
};
|
||||||
|
splitEdgeAtExistingNode: {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
splitEdgeId: Id<"edges">;
|
||||||
|
middleNodeId: Id<"nodes">;
|
||||||
|
splitSourceHandle?: string;
|
||||||
|
splitTargetHandle?: string;
|
||||||
|
newNodeSourceHandle?: string;
|
||||||
|
newNodeTargetHandle?: string;
|
||||||
|
positionX?: number;
|
||||||
|
positionY?: number;
|
||||||
|
clientRequestId: string;
|
||||||
|
};
|
||||||
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 };
|
||||||
@@ -215,9 +244,11 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
|||||||
type !== "createNode" &&
|
type !== "createNode" &&
|
||||||
type !== "createNodeWithEdgeFromSource" &&
|
type !== "createNodeWithEdgeFromSource" &&
|
||||||
type !== "createNodeWithEdgeToTarget" &&
|
type !== "createNodeWithEdgeToTarget" &&
|
||||||
|
type !== "createNodeWithEdgeSplit" &&
|
||||||
type !== "createEdge" &&
|
type !== "createEdge" &&
|
||||||
type !== "removeEdge" &&
|
type !== "removeEdge" &&
|
||||||
type !== "batchRemoveNodes" &&
|
type !== "batchRemoveNodes" &&
|
||||||
|
type !== "splitEdgeAtExistingNode" &&
|
||||||
type !== "moveNode" &&
|
type !== "moveNode" &&
|
||||||
type !== "resizeNode" &&
|
type !== "resizeNode" &&
|
||||||
type !== "updateData"
|
type !== "updateData"
|
||||||
@@ -368,6 +399,61 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === "createNodeWithEdgeSplit" &&
|
||||||
|
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.splitEdgeId === "string" &&
|
||||||
|
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,
|
||||||
|
splitEdgeId: payload.splitEdgeId as Id<"edges">,
|
||||||
|
newNodeTargetHandle:
|
||||||
|
typeof payload.newNodeTargetHandle === "string"
|
||||||
|
? payload.newNodeTargetHandle
|
||||||
|
: undefined,
|
||||||
|
newNodeSourceHandle:
|
||||||
|
typeof payload.newNodeSourceHandle === "string"
|
||||||
|
? payload.newNodeSourceHandle
|
||||||
|
: undefined,
|
||||||
|
splitSourceHandle:
|
||||||
|
typeof payload.splitSourceHandle === "string"
|
||||||
|
? payload.splitSourceHandle
|
||||||
|
: undefined,
|
||||||
|
splitTargetHandle:
|
||||||
|
typeof payload.splitTargetHandle === "string"
|
||||||
|
? payload.splitTargetHandle
|
||||||
|
: undefined,
|
||||||
|
clientRequestId: payload.clientRequestId,
|
||||||
|
},
|
||||||
|
enqueuedAt,
|
||||||
|
attemptCount,
|
||||||
|
nextRetryAt,
|
||||||
|
expiresAt,
|
||||||
|
lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
type === "createEdge" &&
|
type === "createEdge" &&
|
||||||
typeof payload.canvasId === "string" &&
|
typeof payload.canvasId === "string" &&
|
||||||
@@ -440,6 +526,49 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === "splitEdgeAtExistingNode" &&
|
||||||
|
typeof payload.canvasId === "string" &&
|
||||||
|
typeof payload.splitEdgeId === "string" &&
|
||||||
|
typeof payload.middleNodeId === "string" &&
|
||||||
|
typeof payload.clientRequestId === "string"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
canvasId,
|
||||||
|
type,
|
||||||
|
payload: {
|
||||||
|
canvasId: payload.canvasId as Id<"canvases">,
|
||||||
|
splitEdgeId: payload.splitEdgeId as Id<"edges">,
|
||||||
|
middleNodeId: payload.middleNodeId as Id<"nodes">,
|
||||||
|
splitSourceHandle:
|
||||||
|
typeof payload.splitSourceHandle === "string"
|
||||||
|
? payload.splitSourceHandle
|
||||||
|
: undefined,
|
||||||
|
splitTargetHandle:
|
||||||
|
typeof payload.splitTargetHandle === "string"
|
||||||
|
? payload.splitTargetHandle
|
||||||
|
: undefined,
|
||||||
|
newNodeSourceHandle:
|
||||||
|
typeof payload.newNodeSourceHandle === "string"
|
||||||
|
? payload.newNodeSourceHandle
|
||||||
|
: undefined,
|
||||||
|
newNodeTargetHandle:
|
||||||
|
typeof payload.newNodeTargetHandle === "string"
|
||||||
|
? payload.newNodeTargetHandle
|
||||||
|
: undefined,
|
||||||
|
positionX: typeof payload.positionX === "number" ? payload.positionX : undefined,
|
||||||
|
positionY: typeof payload.positionY === "number" ? payload.positionY : undefined,
|
||||||
|
clientRequestId: payload.clientRequestId,
|
||||||
|
},
|
||||||
|
enqueuedAt,
|
||||||
|
attemptCount,
|
||||||
|
nextRetryAt,
|
||||||
|
expiresAt,
|
||||||
|
lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
type === "moveNode" &&
|
type === "moveNode" &&
|
||||||
typeof payload.nodeId === "string" &&
|
typeof payload.nodeId === "string" &&
|
||||||
@@ -727,6 +856,18 @@ function remapNodeIdInPayload(
|
|||||||
return { ...op, payload: next };
|
return { ...op, payload: next };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (op.type === "createNodeWithEdgeSplit" && op.payload.parentId === fromNodeId) {
|
||||||
|
return {
|
||||||
|
...op,
|
||||||
|
payload: { ...op.payload, parentId: toNodeId as Id<"nodes"> },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (op.type === "splitEdgeAtExistingNode" && op.payload.middleNodeId === fromNodeId) {
|
||||||
|
return {
|
||||||
|
...op,
|
||||||
|
payload: { ...op.payload, middleNodeId: toNodeId as Id<"nodes"> },
|
||||||
|
};
|
||||||
|
}
|
||||||
if (op.type === "moveNode" && op.payload.nodeId === fromNodeId) {
|
if (op.type === "moveNode" && op.payload.nodeId === fromNodeId) {
|
||||||
return {
|
return {
|
||||||
...op,
|
...op,
|
||||||
@@ -833,6 +974,12 @@ function opTouchesNodeId(op: CanvasSyncOp, nodeIdSet: ReadonlySet<string>): bool
|
|||||||
(op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId))
|
(op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (op.type === "createNodeWithEdgeSplit") {
|
||||||
|
return op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId);
|
||||||
|
}
|
||||||
|
if (op.type === "splitEdgeAtExistingNode") {
|
||||||
|
return nodeIdSet.has(op.payload.middleNodeId);
|
||||||
|
}
|
||||||
if (op.type === "batchRemoveNodes") {
|
if (op.type === "batchRemoveNodes") {
|
||||||
return op.payload.nodeIds.some((nodeId) => nodeIdSet.has(nodeId));
|
return op.payload.nodeIds.some((nodeId) => nodeIdSet.has(nodeId));
|
||||||
}
|
}
|
||||||
@@ -852,6 +999,12 @@ function opHasClientRequestId(op: CanvasSyncOp, clientRequestIdSet: ReadonlySet<
|
|||||||
if (op.type === "createEdge") {
|
if (op.type === "createEdge") {
|
||||||
return clientRequestIdSet.has(op.payload.clientRequestId);
|
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||||
}
|
}
|
||||||
|
if (op.type === "createNodeWithEdgeSplit") {
|
||||||
|
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||||
|
}
|
||||||
|
if (op.type === "splitEdgeAtExistingNode") {
|
||||||
|
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,6 +1012,12 @@ function opTouchesEdgeId(op: CanvasSyncOp, edgeIdSet: ReadonlySet<string>): bool
|
|||||||
if (op.type === "removeEdge") {
|
if (op.type === "removeEdge") {
|
||||||
return edgeIdSet.has(op.payload.edgeId);
|
return edgeIdSet.has(op.payload.edgeId);
|
||||||
}
|
}
|
||||||
|
if (op.type === "createNodeWithEdgeSplit") {
|
||||||
|
return edgeIdSet.has(op.payload.splitEdgeId);
|
||||||
|
}
|
||||||
|
if (op.type === "splitEdgeAtExistingNode") {
|
||||||
|
return edgeIdSet.has(op.payload.splitEdgeId);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user