diff --git a/components/canvas/CLAUDE.md b/components/canvas/CLAUDE.md index 3556c16..5e7b444 100644 --- a/components/canvas/CLAUDE.md +++ b/components/canvas/CLAUDE.md @@ -122,10 +122,10 @@ Compare-Node hat zusätzlich Handle-spezifische Farben (`left` → Blau, `right` - Key-Schema: `lemonspace.canvas:snapshot:v1:` und `lemonspace.canvas:ops:v1:` - 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. --- diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 75645f9..0dc7ae5 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -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; + 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; + 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; + return typeof record.splitEdgeId === "string"; +} + export function getPendingMovePinsFromLocalOps( canvasId: string, ): Map { @@ -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 { + const edgeIds = new Set(); + 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[], diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index e94fb1e..27ddd8e 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -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, diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 405ede6..fcb4131 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -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([]); const [edges, setEdges] = useState([]); + 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[0]) => { - if (!isSyncOnline) { - notifyOfflineUnsupported("Kanten-Split"); - throw new Error("offline-unsupported"); + async (args: Parameters[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[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( @@ -1415,15 +1586,17 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ) : undefined; const glowMode = resolvedTheme === "dark" ? "dark" : "light"; - const mapped = convexEdges.map((edge: Doc<"edges">) => - sourceTypeByNodeId - ? convexEdgeToRFWithSourceGlow( - edge, - sourceTypeByNodeId.get(edge.sourceNodeId), - glowMode, - ) - : convexEdgeToRF(edge), - ); + const mapped = convexEdges + .filter((edge: Doc<"edges">) => !pendingRemovedEdgeIds.has(edge._id as string)) + .map((edge: Doc<"edges">) => + sourceTypeByNodeId + ? convexEdgeToRFWithSourceGlow( + edge, + sourceTypeByNodeId.get(edge.sourceNodeId), + glowMode, + ) + : convexEdgeToRF(edge), + ); const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature)); const convexNodeIds = @@ -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; diff --git a/convex/CLAUDE.md b/convex/CLAUDE.md index 994b10f..f83b394 100644 --- a/convex/CLAUDE.md +++ b/convex/CLAUDE.md @@ -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. --- diff --git a/convex/nodes.ts b/convex/nodes.ts index 7a1bc39..4828b7d 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -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(), + }); + } }, }); diff --git a/lib/canvas-local-persistence.ts b/lib/canvas-local-persistence.ts index ee472e7..8257faa 100644 --- a/lib/canvas-local-persistence.ts +++ b/lib/canvas-local-persistence.ts @@ -190,7 +190,8 @@ function opTouchesNodeId(op: CanvasPendingOp, nodeIdSet: ReadonlySet): 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): 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)) { diff --git a/lib/canvas-op-queue.ts b/lib/canvas-op-queue.ts index 866b0cc..bab6882 100644 --- a/lib/canvas-op-queue.ts +++ b/lib/canvas-op-queue.ts @@ -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): 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): 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; }