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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RFNode[]>([]);
|
||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||
function HookHarness({
|
||||
canvasId,
|
||||
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 deletingNodeIds = useRef(new Set<string>());
|
||||
const [, setAssetBrowserTargetNodeId] = useState<string | null>(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<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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<void> {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -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<typeof createNode>[0] & { clientRequestId: string },
|
||||
args: Parameters<typeof createNode>[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<typeof createNodeWithEdgeSplitMut>[0]) => {
|
||||
async (args: Parameters<typeof createNodeWithEdgeSplitRaw>[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<RunBatchRemoveNodesMutation>(
|
||||
|
||||
Reference in New Issue
Block a user