From b7771764d8e7df97a802325032f8823b311dca78 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Mon, 6 Apr 2026 11:08:32 +0200 Subject: [PATCH] feat(canvas): enhance edge insertion animations and update reflow duration - Added a new CSS animation for edge insertion to improve visual feedback during node creation. - Updated the edge insertion reflow duration from 997ms to 1297ms for smoother transitions. - Refactored related components to support the new animation and ensure consistent behavior across the canvas. - Enhanced tests to validate the new edge insertion features and animations. --- app/globals.css | 28 ++++- .../use-canvas-sync-engine-hook.test.tsx | 113 +++++++++++++++++- components/canvas/canvas.tsx | 2 +- .../canvas/use-canvas-edge-insertions.ts | 2 +- components/canvas/use-canvas-sync-engine.ts | 69 +++++++++-- 5 files changed, 199 insertions(+), 15 deletions(-) diff --git a/app/globals.css b/app/globals.css index 056aadc..a1c65c7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -145,6 +145,17 @@ } } +@keyframes ls-edge-insert-enter { + 0% { + opacity: 0; + scale: 0.82; + } + 100% { + opacity: 1; + scale: 1; + } +} + .ls-connection-line-marching { animation: ls-connection-dash-offset 0.4s linear infinite; } @@ -250,11 +261,18 @@ .react-flow.canvas-edge-insert-reflowing .react-flow__node { transition-property: transform; - transition-duration: var(--ls-edge-insert-reflow-duration, 997ms); + transition-duration: var(--ls-edge-insert-reflow-duration, 1297ms); transition-timing-function: cubic-bezier(0.68, -0.6, 0.32, 1.6); will-change: transform; } + .react-flow__node.canvas-edge-insert-enter, + .react-flow__node .canvas-edge-insert-enter { + animation: ls-edge-insert-enter 300ms cubic-bezier(0.68, -0.6, 0.32, 1.6) both; + transform-origin: center center; + will-change: scale, opacity; + } + @media (prefers-reduced-motion: reduce) { .react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path { transition: none; @@ -263,5 +281,13 @@ .react-flow.canvas-edge-insert-reflowing .react-flow__node { transition: none; } + + .react-flow__node.canvas-edge-insert-enter, + .react-flow__node .canvas-edge-insert-enter { + animation: none; + opacity: 1; + scale: 1; + will-change: auto; + } } } diff --git a/components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx b/components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx index 549003e..9761683 100644 --- a/components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx +++ b/components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx @@ -90,9 +90,17 @@ const latestHookValueRef: { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -function HookHarness({ canvasId }: { canvasId: Id<"canvases"> }) { - const [, setNodes] = useState([]); - const [edges, setEdges] = useState([]); +function HookHarness({ + canvasId, + initialNodes = [], + initialEdges = [], +}: { + canvasId: Id<"canvases">; + initialNodes?: RFNode[]; + initialEdges?: RFEdge[]; +}) { + const [nodes, setNodes] = useState(initialNodes); + const [edges, setEdges] = useState(initialEdges); const edgesRef = useRef(edges); const deletingNodeIds = useRef(new Set()); const [, setAssetBrowserTargetNodeId] = useState(null); @@ -120,10 +128,15 @@ function HookHarness({ canvasId }: { canvasId: Id<"canvases"> }) { latestEdgesRef.current = edges; }, [edges]); + useEffect(() => { + latestNodesRef.current = nodes; + }, [nodes]); + return null; } const latestEdgesRef: { current: RFEdge[] } = { current: [] }; +const latestNodesRef: { current: RFNode[] } = { current: [] }; function setNavigatorOnline(online: boolean) { Object.defineProperty(window.navigator, "onLine", { @@ -139,6 +152,7 @@ describe("useCanvasSyncEngine hook wiring", () => { afterEach(async () => { latestHookValueRef.current = null; latestEdgesRef.current = []; + latestNodesRef.current = []; setNavigatorOnline(true); mocks.mutationMocks.clear(); vi.clearAllMocks(); @@ -281,4 +295,97 @@ describe("useCanvasSyncEngine hook wiring", () => { latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-2"), ).toBe(false); }); + + it("adds optimistic split node and edges immediately in online mode before mutation resolves", async () => { + let createSplitResolver!: (value: string) => void; + const createSplitPromise = new Promise((resolve) => { + createSplitResolver = resolve; + }); + const createSplitMutation = vi.fn(() => createSplitPromise); + Object.assign(createSplitMutation, { + withOptimisticUpdate: () => createSplitMutation, + }); + mocks.mutationMocks.set("nodes.createWithEdgeSplit", createSplitMutation); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + let pendingCreateSplit: Promise> | undefined; + await act(async () => { + pendingCreateSplit = latestHookValueRef.current?.actions.createNodeWithEdgeSplit({ + canvasId: asCanvasId("canvas-1"), + type: "note", + positionX: 180, + positionY: 0, + width: 140, + height: 100, + data: {}, + splitEdgeId: "edge-base" as Id<"edges">, + splitSourceHandle: "source-handle", + splitTargetHandle: "target-handle", + newNodeSourceHandle: "new-source-handle", + newNodeTargetHandle: "new-target-handle", + clientRequestId: "split-1", + }); + await Promise.resolve(); + }); + + expect( + latestNodesRef.current.find((node) => node.id === "optimistic_split-1"), + ).toMatchObject({ + className: "canvas-edge-insert-enter", + }); + expect( + latestEdgesRef.current.some((edge) => edge.id === "edge-base"), + ).toBe(false); + expect( + latestEdgesRef.current.some( + (edge) => edge.id === "optimistic_edge_split-1_split_a", + ), + ).toBe(true); + expect( + latestEdgesRef.current.some( + (edge) => edge.id === "optimistic_edge_split-1_split_b", + ), + ).toBe(true); + + createSplitResolver("node-real-split-1"); + await act(async () => { + await pendingCreateSplit; + }); + }); }); diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 67c0982..b5ac195 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -82,7 +82,7 @@ interface CanvasInnerProps { canvasId: Id<"canvases">; } -const EDGE_INSERT_REFLOW_SETTLE_MS = 997; +const EDGE_INSERT_REFLOW_SETTLE_MS = 1297; function CanvasInner({ canvasId }: CanvasInnerProps) { const t = useTranslations('toasts'); diff --git a/components/canvas/use-canvas-edge-insertions.ts b/components/canvas/use-canvas-edge-insertions.ts index 45f5119..2b935ba 100644 --- a/components/canvas/use-canvas-edge-insertions.ts +++ b/components/canvas/use-canvas-edge-insertions.ts @@ -27,7 +27,7 @@ export type EdgeInsertMenuState = { }; const EDGE_INSERT_GAP_PX = 10; -const DEFAULT_REFLOW_SETTLE_MS = 997; +const DEFAULT_REFLOW_SETTLE_MS = 1297; function waitForReflowSettle(ms: number): Promise { return new Promise((resolve) => { diff --git a/components/canvas/use-canvas-sync-engine.ts b/components/canvas/use-canvas-sync-engine.ts index b2f5460..35057ce 100644 --- a/components/canvas/use-canvas-sync-engine.ts +++ b/components/canvas/use-canvas-sync-engine.ts @@ -746,8 +746,6 @@ export function useCanvasSyncEngine({ ]); }); - const createNodeWithEdgeSplitMut = useMutation(api.nodes.createWithEdgeSplit); - const createEdge = useMutation(api.edges.create).withOptimisticUpdate( (localStore, args) => { const edgeList = localStore.getQuery(api.edges.list, { @@ -849,7 +847,10 @@ export function useCanvasSyncEngine({ const addOptimisticNodeLocally = useCallback( ( - args: Parameters[0] & { clientRequestId: string }, + args: Parameters[0] & { + clientRequestId: string; + className?: string; + }, ): Id<"nodes"> => { const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`; setNodes((current) => { @@ -866,6 +867,7 @@ export function useCanvasSyncEngine({ style: { width: args.width, height: args.height }, parentId: args.parentId as string | undefined, zIndex: args.zIndex, + className: args.className, selected: false, }, ]; @@ -1390,15 +1392,24 @@ export function useCanvasSyncEngine({ ); const runCreateNodeWithEdgeSplitOnlineOnly = useCallback( - async (args: Parameters[0]) => { + async (args: Parameters[0]) => { const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); const payload = { ...args, clientRequestId }; + const splitEdgeId = payload.splitEdgeId as string; - if (isSyncOnline) { - return await createNodeWithEdgeSplitMut(payload); - } + controller.pendingConnectionCreatesRef.current.add(clientRequestId); - const optimisticNodeId = addOptimisticNodeLocally(payload); + const originalSplitEdge = edgesRef.current.find( + (edge) => + edge.id === splitEdgeId && + edge.className !== "temp" && + !isOptimisticEdgeId(edge.id), + ); + + const optimisticNodeId = addOptimisticNodeLocally({ + ...payload, + className: "canvas-edge-insert-enter", + }); const splitApplied = applyEdgeSplitLocally({ clientRequestId, splitEdgeId: payload.splitEdgeId, @@ -1411,6 +1422,34 @@ export function useCanvasSyncEngine({ positionY: payload.positionY, }); + if (isSyncOnline) { + try { + const realId = await trackPendingNodeCreate( + clientRequestId, + createNodeWithEdgeSplitRaw({ ...payload }), + ); + await remapOptimisticNodeLocally(clientRequestId, realId); + return realId; + } catch (error) { + removeOptimisticCreateLocally({ + clientRequestId, + removeNode: true, + removeEdge: true, + }); + + if (splitApplied && originalSplitEdge) { + setEdges((current) => { + if (current.some((edge) => edge.id === originalSplitEdge.id)) { + return current; + } + return [...current, originalSplitEdge]; + }); + } + + throw error; + } + } + if (splitApplied) { await enqueueSyncMutation("createNodeWithEdgeSplit", payload); } else { @@ -1430,7 +1469,19 @@ export function useCanvasSyncEngine({ return optimisticNodeId; }, - [addOptimisticNodeLocally, applyEdgeSplitLocally, createNodeWithEdgeSplitMut, enqueueSyncMutation, isSyncOnline], + [ + addOptimisticNodeLocally, + applyEdgeSplitLocally, + controller.pendingConnectionCreatesRef, + createNodeWithEdgeSplitRaw, + edgesRef, + enqueueSyncMutation, + isSyncOnline, + remapOptimisticNodeLocally, + removeOptimisticCreateLocally, + setEdges, + trackPendingNodeCreate, + ], ); const runBatchRemoveNodesMutation = useCallback(