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 {
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user