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.
This commit is contained in:
2026-04-06 11:08:32 +02:00
parent b47720a50b
commit b7771764d8
5 changed files with 199 additions and 15 deletions

View File

@@ -145,6 +145,17 @@
} }
} }
@keyframes ls-edge-insert-enter {
0% {
opacity: 0;
scale: 0.82;
}
100% {
opacity: 1;
scale: 1;
}
}
.ls-connection-line-marching { .ls-connection-line-marching {
animation: ls-connection-dash-offset 0.4s linear infinite; animation: ls-connection-dash-offset 0.4s linear infinite;
} }
@@ -250,11 +261,18 @@
.react-flow.canvas-edge-insert-reflowing .react-flow__node { .react-flow.canvas-edge-insert-reflowing .react-flow__node {
transition-property: transform; 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); transition-timing-function: cubic-bezier(0.68, -0.6, 0.32, 1.6);
will-change: transform; 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) { @media (prefers-reduced-motion: reduce) {
.react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path { .react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path {
transition: none; transition: none;
@@ -263,5 +281,13 @@
.react-flow.canvas-edge-insert-reflowing .react-flow__node { .react-flow.canvas-edge-insert-reflowing .react-flow__node {
transition: none; 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;
}
} }
} }

View File

@@ -90,9 +90,17 @@ const latestHookValueRef: {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function HookHarness({ canvasId }: { canvasId: Id<"canvases"> }) { function HookHarness({
const [, setNodes] = useState<RFNode[]>([]); canvasId,
const [edges, setEdges] = useState<RFEdge[]>([]); initialNodes = [],
initialEdges = [],
}: {
canvasId: Id<"canvases">;
initialNodes?: RFNode[];
initialEdges?: RFEdge[];
}) {
const [nodes, setNodes] = useState<RFNode[]>(initialNodes);
const [edges, setEdges] = useState<RFEdge[]>(initialEdges);
const edgesRef = useRef<RFEdge[]>(edges); const edgesRef = useRef<RFEdge[]>(edges);
const deletingNodeIds = useRef(new Set<string>()); const deletingNodeIds = useRef(new Set<string>());
const [, setAssetBrowserTargetNodeId] = useState<string | null>(null); const [, setAssetBrowserTargetNodeId] = useState<string | null>(null);
@@ -120,10 +128,15 @@ function HookHarness({ canvasId }: { canvasId: Id<"canvases"> }) {
latestEdgesRef.current = edges; latestEdgesRef.current = edges;
}, [edges]); }, [edges]);
useEffect(() => {
latestNodesRef.current = nodes;
}, [nodes]);
return null; return null;
} }
const latestEdgesRef: { current: RFEdge[] } = { current: [] }; const latestEdgesRef: { current: RFEdge[] } = { current: [] };
const latestNodesRef: { current: RFNode[] } = { current: [] };
function setNavigatorOnline(online: boolean) { function setNavigatorOnline(online: boolean) {
Object.defineProperty(window.navigator, "onLine", { Object.defineProperty(window.navigator, "onLine", {
@@ -139,6 +152,7 @@ describe("useCanvasSyncEngine hook wiring", () => {
afterEach(async () => { afterEach(async () => {
latestHookValueRef.current = null; latestHookValueRef.current = null;
latestEdgesRef.current = []; latestEdgesRef.current = [];
latestNodesRef.current = [];
setNavigatorOnline(true); setNavigatorOnline(true);
mocks.mutationMocks.clear(); mocks.mutationMocks.clear();
vi.clearAllMocks(); vi.clearAllMocks();
@@ -281,4 +295,97 @@ describe("useCanvasSyncEngine hook wiring", () => {
latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-2"), latestEdgesRef.current.some((edge) => edge.id === "optimistic_edge_req-2"),
).toBe(false); ).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<string>((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(
<HookHarness
canvasId={asCanvasId("canvas-1")}
initialNodes={[
{
id: "node-source",
type: "note",
position: { x: 0, y: 0 },
data: {},
style: { width: 120, height: 80 },
},
{
id: "node-target",
type: "note",
position: { x: 360, y: 0 },
data: {},
style: { width: 120, height: 80 },
},
]}
initialEdges={[
{
id: "edge-base",
source: "node-source",
target: "node-target",
sourceHandle: "source-handle",
targetHandle: "target-handle",
},
]}
/>,
);
});
let pendingCreateSplit: Promise<Id<"nodes">> | 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;
});
});
}); });

View File

@@ -82,7 +82,7 @@ interface CanvasInnerProps {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
} }
const EDGE_INSERT_REFLOW_SETTLE_MS = 997; const EDGE_INSERT_REFLOW_SETTLE_MS = 1297;
function CanvasInner({ canvasId }: CanvasInnerProps) { function CanvasInner({ canvasId }: CanvasInnerProps) {
const t = useTranslations('toasts'); const t = useTranslations('toasts');

View File

@@ -27,7 +27,7 @@ export type EdgeInsertMenuState = {
}; };
const EDGE_INSERT_GAP_PX = 10; const EDGE_INSERT_GAP_PX = 10;
const DEFAULT_REFLOW_SETTLE_MS = 997; const DEFAULT_REFLOW_SETTLE_MS = 1297;
function waitForReflowSettle(ms: number): Promise<void> { function waitForReflowSettle(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -746,8 +746,6 @@ export function useCanvasSyncEngine({
]); ]);
}); });
const createNodeWithEdgeSplitMut = useMutation(api.nodes.createWithEdgeSplit);
const createEdge = useMutation(api.edges.create).withOptimisticUpdate( const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
(localStore, args) => { (localStore, args) => {
const edgeList = localStore.getQuery(api.edges.list, { const edgeList = localStore.getQuery(api.edges.list, {
@@ -849,7 +847,10 @@ export function useCanvasSyncEngine({
const addOptimisticNodeLocally = useCallback( const addOptimisticNodeLocally = useCallback(
( (
args: Parameters<typeof createNode>[0] & { clientRequestId: string }, args: Parameters<typeof createNode>[0] & {
clientRequestId: string;
className?: string;
},
): Id<"nodes"> => { ): Id<"nodes"> => {
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`; const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`;
setNodes((current) => { setNodes((current) => {
@@ -866,6 +867,7 @@ export function useCanvasSyncEngine({
style: { width: args.width, height: args.height }, style: { width: args.width, height: args.height },
parentId: args.parentId as string | undefined, parentId: args.parentId as string | undefined,
zIndex: args.zIndex, zIndex: args.zIndex,
className: args.className,
selected: false, selected: false,
}, },
]; ];
@@ -1390,15 +1392,24 @@ export function useCanvasSyncEngine({
); );
const runCreateNodeWithEdgeSplitOnlineOnly = useCallback( const runCreateNodeWithEdgeSplitOnlineOnly = useCallback(
async (args: Parameters<typeof createNodeWithEdgeSplitMut>[0]) => { async (args: Parameters<typeof createNodeWithEdgeSplitRaw>[0]) => {
const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId }; const payload = { ...args, clientRequestId };
const splitEdgeId = payload.splitEdgeId as string;
if (isSyncOnline) { controller.pendingConnectionCreatesRef.current.add(clientRequestId);
return await createNodeWithEdgeSplitMut(payload);
}
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({ const splitApplied = applyEdgeSplitLocally({
clientRequestId, clientRequestId,
splitEdgeId: payload.splitEdgeId, splitEdgeId: payload.splitEdgeId,
@@ -1411,6 +1422,34 @@ export function useCanvasSyncEngine({
positionY: payload.positionY, 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) { if (splitApplied) {
await enqueueSyncMutation("createNodeWithEdgeSplit", payload); await enqueueSyncMutation("createNodeWithEdgeSplit", payload);
} else { } else {
@@ -1430,7 +1469,19 @@ export function useCanvasSyncEngine({
return optimisticNodeId; return optimisticNodeId;
}, },
[addOptimisticNodeLocally, applyEdgeSplitLocally, createNodeWithEdgeSplitMut, enqueueSyncMutation, isSyncOnline], [
addOptimisticNodeLocally,
applyEdgeSplitLocally,
controller.pendingConnectionCreatesRef,
createNodeWithEdgeSplitRaw,
edgesRef,
enqueueSyncMutation,
isSyncOnline,
remapOptimisticNodeLocally,
removeOptimisticCreateLocally,
setEdges,
trackPendingNodeCreate,
],
); );
const runBatchRemoveNodesMutation = useCallback<RunBatchRemoveNodesMutation>( const runBatchRemoveNodesMutation = useCallback<RunBatchRemoveNodesMutation>(