Enable offline edge split sync and stabilize local edge state

This commit is contained in:
Matthias
2026-04-01 11:04:40 +02:00
parent f9b15613c5
commit eb5ed06ced
8 changed files with 506 additions and 39 deletions

View File

@@ -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>`
- 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).
- 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.
---

View File

@@ -366,6 +366,43 @@ function isBatchMoveNodesOpPayload(
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(
canvasId: string,
): Map<string, { x: number; y: number }> {
@@ -385,11 +422,43 @@ export function getPendingMovePinsFromLocalOps(
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;
}
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(
previousNodes: RFNode[],
incomingNodes: RFNode[],

View File

@@ -11,6 +11,7 @@ import { useStore, type Edge as RFEdge } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
import { isOptimisticEdgeId } from "./canvas-helpers";
type CreateNodeArgs = {
canvasId: Id<"canvases">;
@@ -40,6 +41,7 @@ type CreateNodeWithEdgeSplitArgs = {
newNodeSourceHandle?: string;
splitSourceHandle?: string;
splitTargetHandle?: string;
clientRequestId?: string;
};
type CreateNodeWithEdgeFromSourceArgs = CreateNodeArgs & {
@@ -143,7 +145,9 @@ function getIntersectedPersistedEdge(
if (!edgeId) return undefined;
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;
}
@@ -253,16 +257,11 @@ export function CanvasPlacementProvider({
newNodeSourceHandle: normalizeHandle(handles.source),
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
...(clientRequestId !== undefined ? { clientRequestId } : {}),
});
notifySettled(realId);
return realId;
} catch (error) {
if (
error instanceof Error &&
error.message === "offline-unsupported"
) {
throw error;
}
console.error("[Canvas placement] edge split failed", {
edgeId: hitEdge.id,
type,

View File

@@ -105,6 +105,7 @@ import {
getMiniMapNodeStrokeColor,
getNodeCenterClientPosition,
getIntersectedEdgeId,
getPendingRemovedEdgeIdsFromLocalOps,
getPendingMovePinsFromLocalOps,
hasHandleKey,
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(
(localStore, args) => {
@@ -445,12 +446,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const createNodeWithEdgeToTargetRaw = useMutation(
api.nodes.createWithEdgeToTarget,
);
const createNodeWithEdgeSplitRaw = useMutation(api.nodes.createWithEdgeSplit);
const createEdgeRaw = useMutation(api.edges.create);
const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove);
const removeEdgeRaw = useMutation(api.edges.remove);
const splitEdgeAtExistingNodeRaw = useMutation(
api.nodes.splitEdgeAtExistingNode,
);
const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]);
const edgesRef = useRef(edges);
edgesRef.current = edges;
const [pendingSyncCount, setPendingSyncCount] = useState(0);
const [isSyncing, setIsSyncing] = useState(false);
const [isBrowserOnline, setIsBrowserOnline] = useState(
@@ -534,6 +541,84 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
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: {
clientRequestId: string;
removeNode?: boolean;
@@ -555,8 +640,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}
if (args.removeEdge) {
const optimisticEdgePrefix = `${optimisticEdgeId}_`;
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(
async (args: Parameters<typeof createNodeWithEdgeSplit>[0]) => {
if (!isSyncOnline) {
notifyOfflineUnsupported("Kanten-Split");
throw new Error("offline-unsupported");
async (args: Parameters<typeof createNodeWithEdgeSplitMut>[0]) => {
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId };
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 () => {
@@ -771,12 +894,23 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
realId,
);
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") {
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 === "splitEdgeAtExistingNode") {
await splitEdgeAtExistingNodeRaw(op.payload);
setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "moveNode") {
await moveNode(op.payload);
} else if (op.type === "resizeNode") {
@@ -814,11 +948,24 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
removeNode: 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") {
removeOptimisticCreateLocally({
clientRequestId: op.payload.clientRequestId,
removeEdge: true,
});
} else if (op.type === "splitEdgeAtExistingNode") {
removeOptimisticCreateLocally({
clientRequestId: op.payload.clientRequestId,
removeEdge: true,
});
setEdgeSyncNonce((value) => value + 1);
}
await ackCanvasSyncOp(op.id);
resolveCanvasOp(canvasId as string, op.id);
@@ -842,6 +989,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
createEdgeRaw,
createNodeRaw,
createNodeWithEdgeFromSourceRaw,
createNodeWithEdgeSplitRaw,
createNodeWithEdgeToTargetRaw,
isSyncOnline,
moveNode,
@@ -850,6 +998,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
removeEdgeRaw,
removeOptimisticCreateLocally,
resizeNode,
splitEdgeAtExistingNodeRaw,
updateNodeData,
]);
@@ -1124,13 +1273,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const runSplitEdgeAtExistingNodeMutation = useCallback(
async (args: Parameters<typeof splitEdgeAtExistingNodeMut>[0]) => {
if (!isSyncOnline) {
notifyOfflineUnsupported("Kanten-Split");
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId };
if (isSyncOnline) {
await splitEdgeAtExistingNodeMut(payload);
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. */
@@ -1334,8 +1504,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
}, [scissorsMode, navTool]);
const edgesRef = useRef(edges);
edgesRef.current = edges;
const scissorsModeRef = useRef(scissorsMode);
scissorsModeRef.current = scissorsMode;
@@ -1408,6 +1576,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}
const tempEdges = prev.filter((e) => e.className === "temp");
const pendingRemovedEdgeIds = getPendingRemovedEdgeIdsFromLocalOps(
canvasId as string,
);
const sourceTypeByNodeId =
convexNodes !== undefined
? new Map<string, string>(
@@ -1415,7 +1586,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
)
: undefined;
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
? convexEdgeToRFWithSourceGlow(
edge,
@@ -1562,7 +1735,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
return [...mapped, ...carriedOptimistic, ...tempEdges];
});
}, [convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
}, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
useLayoutEffect(() => {
if (!convexNodes || isResizing.current) return;
@@ -1776,7 +1949,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}
const intersectedEdge = edges.find(
(edge) => edge.id === intersectedEdgeId && edge.className !== "temp",
(edge) =>
edge.id === intersectedEdgeId &&
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id),
);
if (!intersectedEdge) {
overlappedEdgeRef.current = null;
@@ -1836,7 +2012,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const intersectedEdge = intersectedEdgeId
? edges.find(
(edge) =>
edge.id === intersectedEdgeId && edge.className !== "temp",
edge.id === intersectedEdgeId &&
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id),
)
: undefined;

View File

@@ -123,8 +123,9 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu
### 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.
- `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.
---

View File

@@ -36,6 +36,7 @@ async function getCanvasIfAuthorized(
type NodeCreateMutationName =
| "nodes.create"
| "nodes.createWithEdgeSplit"
| "nodes.createWithEdgeFromSource"
| "nodes.createWithEdgeToTarget";
@@ -248,11 +249,22 @@ export const createWithEdgeSplit = mutation({
newNodeSourceHandle: v.optional(v.string()),
splitSourceHandle: v.optional(v.string()),
splitTargetHandle: v.optional(v.string()),
clientRequestId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
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);
if (!edge || edge.canvasId !== args.canvasId) {
throw new Error("Edge not found");
@@ -290,6 +302,13 @@ export const createWithEdgeSplit = mutation({
await ctx.db.delete(args.splitEdgeId);
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;
},
@@ -310,11 +329,34 @@ export const splitEdgeAtExistingNode = mutation({
newNodeTargetHandle: v.optional(v.string()),
positionX: v.optional(v.number()),
positionY: v.optional(v.number()),
clientRequestId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
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);
if (!edge || edge.canvasId !== args.canvasId) {
throw new Error("Edge not found");
@@ -360,6 +402,18 @@ export const splitEdgeAtExistingNode = mutation({
await ctx.db.delete(args.splitEdgeId);
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(),
});
}
},
});

View File

@@ -190,7 +190,8 @@ function opTouchesNodeId(op: CanvasPendingOp, nodeIdSet: ReadonlySet<string>): b
(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))
(typeof payload.parentId === "string" && nodeIdSet.has(payload.parentId)) ||
(typeof payload.middleNodeId === "string" && nodeIdSet.has(payload.middleNodeId))
) {
return true;
}
@@ -227,8 +228,10 @@ function opHasClientRequestId(
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)
(typeof op.payload.edgeId === "string" &&
edgeIdSet.has(op.payload.edgeId)) ||
(typeof op.payload.splitEdgeId === "string" &&
edgeIdSet.has(op.payload.splitEdgeId))
);
}
@@ -291,6 +294,10 @@ function remapNodeIdInPayload(
changed = true;
}
}
if (nextPayload.middleNodeId === fromNodeId) {
nextPayload.middleNodeId = toNodeId;
changed = true;
}
const moves = nextPayload.moves;
if (Array.isArray(moves)) {

View File

@@ -49,6 +49,23 @@ export type CanvasSyncOpPayloadByType = {
sourceHandle?: 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: {
canvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
@@ -63,6 +80,18 @@ export type CanvasSyncOpPayloadByType = {
batchRemoveNodes: {
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 };
resizeNode: { nodeId: Id<"nodes">; width: number; height: number };
updateData: { nodeId: Id<"nodes">; data: unknown };
@@ -215,9 +244,11 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
type !== "createNode" &&
type !== "createNodeWithEdgeFromSource" &&
type !== "createNodeWithEdgeToTarget" &&
type !== "createNodeWithEdgeSplit" &&
type !== "createEdge" &&
type !== "removeEdge" &&
type !== "batchRemoveNodes" &&
type !== "splitEdgeAtExistingNode" &&
type !== "moveNode" &&
type !== "resizeNode" &&
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 (
type === "createEdge" &&
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 (
type === "moveNode" &&
typeof payload.nodeId === "string" &&
@@ -727,6 +856,18 @@ function remapNodeIdInPayload(
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) {
return {
...op,
@@ -833,6 +974,12 @@ function opTouchesNodeId(op: CanvasSyncOp, nodeIdSet: ReadonlySet<string>): bool
(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") {
return op.payload.nodeIds.some((nodeId) => nodeIdSet.has(nodeId));
}
@@ -852,6 +999,12 @@ function opHasClientRequestId(op: CanvasSyncOp, clientRequestIdSet: ReadonlySet<
if (op.type === "createEdge") {
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;
}
@@ -859,6 +1012,12 @@ function opTouchesEdgeId(op: CanvasSyncOp, edgeIdSet: ReadonlySet<string>): bool
if (op.type === "removeEdge") {
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;
}