fix: animate canvas agent viewport changes

This commit is contained in:
134 2026-06-23 18:56:49 +08:00
parent 0f74cbd6ff
commit a1a095fe0e
3 changed files with 131 additions and 4 deletions

View File

@ -99,6 +99,10 @@ import {
import {
resolveCanvasViewOperation,
} from "@/lib/canvas/agent-view-operations";
import {
interpolateCanvasAgentViewport,
normalizeCanvasAgentViewAnimationDuration,
} from "@/lib/canvas/agent-view-animation";
import { getCanvasAgentOperationErrorMessage } from "@/lib/canvas/agent-operation-errors";
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
@ -1267,6 +1271,7 @@ export function CanvasChatPanel({
const handledCanvasMediaItemIdsRef = useRef<Set<string>>(new Set());
const pendingCanvasViewOperationRef =
useRef<CanvasViewOperationStreamPayload | null>(null);
const canvasViewAnimationFrameRef = useRef<number | null>(null);
const pendingCanvasAgentAnnotationEditTargetRef = useRef<{
nodeId: string;
requestedAt: number;
@ -1863,6 +1868,57 @@ export function CanvasChatPanel({
[focusCanvasOnNode, setTransientViewport]
);
const animateCanvasAgentViewport = useCallback(
(
targetViewport: { x: number; y: number; zoom: number },
durationMs?: number
) => {
if (typeof window === "undefined") return;
if (canvasViewAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(canvasViewAnimationFrameRef.current);
canvasViewAnimationFrameRef.current = null;
}
const duration = normalizeCanvasAgentViewAnimationDuration(durationMs);
const prefersReducedMotion = window.matchMedia?.(
"(prefers-reduced-motion: reduce)"
)?.matches;
if (duration <= 0 || prefersReducedMotion) {
setTransientViewport(targetViewport);
return;
}
const startViewport = { ...useCanvasStore.getState().viewport };
const startedAt = window.performance.now();
const tick = (timestamp: number) => {
const progress = Math.min(1, (timestamp - startedAt) / duration);
setTransientViewport(
interpolateCanvasAgentViewport(startViewport, targetViewport, progress)
);
if (progress < 1) {
canvasViewAnimationFrameRef.current = window.requestAnimationFrame(tick);
return;
}
canvasViewAnimationFrameRef.current = null;
};
canvasViewAnimationFrameRef.current = window.requestAnimationFrame(tick);
},
[setTransientViewport]
);
useEffect(() => {
return () => {
if (
typeof window !== "undefined" &&
canvasViewAnimationFrameRef.current !== null
) {
window.cancelAnimationFrame(canvasViewAnimationFrameRef.current);
canvasViewAnimationFrameRef.current = null;
}
};
}, []);
const applyCanvasViewOperation = useCallback(
(payload: CanvasViewOperationStreamPayload) => {
if (!payload || typeof payload !== "object") return false;
@ -1883,15 +1939,13 @@ export function CanvasChatPanel({
});
if (!resolved) return false;
window.requestAnimationFrame(() => {
setTransientViewport(resolved.viewport);
});
animateCanvasAgentViewport(resolved.viewport, payload.durationMs);
if (resolved.targetNodeIds.length > 0) {
store.selectNodes(resolved.targetNodeIds);
}
return true;
},
[canvasId, setTransientViewport]
[animateCanvasAgentViewport, canvasId]
);
const handleAssistantVote = useCallback(

View File

@ -0,0 +1,35 @@
import type { CanvasAgentViewportLike } from "@/lib/canvas/agent-view-operations";
export function normalizeCanvasAgentViewAnimationDuration(
durationMs?: unknown,
fallbackMs = 280
) {
const duration = Number(durationMs);
const fallback = Number(fallbackMs);
const normalizedFallback = Number.isFinite(fallback)
? Math.max(0, Math.min(900, fallback))
: 280;
if (!Number.isFinite(duration)) return normalizedFallback;
return Math.max(0, Math.min(900, Math.round(duration)));
}
export function easeOutCanvasAgentViewProgress(progress: number) {
const clamped = Math.max(0, Math.min(1, Number(progress) || 0));
return 1 - (1 - clamped) ** 3;
}
export function interpolateCanvasAgentViewport(
from: CanvasAgentViewportLike,
to: CanvasAgentViewportLike,
progress: number
): CanvasAgentViewportLike {
const easedProgress = easeOutCanvasAgentViewProgress(progress);
const interpolate = (start: number, end: number) =>
start + (end - start) * easedProgress;
return {
x: interpolate(Number(from.x || 0), Number(to.x || 0)),
y: interpolate(Number(from.y || 0), Number(to.y || 0)),
zoom: interpolate(Number(from.zoom || 1), Number(to.zoom || 1)),
};
}

View File

@ -89,6 +89,11 @@ import {
resolveCanvasViewOperation,
resolveCanvasViewOperationTargetNodeIds,
} from "@/lib/canvas/agent-view-operations";
import {
easeOutCanvasAgentViewProgress,
interpolateCanvasAgentViewport,
normalizeCanvasAgentViewAnimationDuration,
} from "@/lib/canvas/agent-view-animation";
import {
getCanvasAgentOperationErrorMessage,
normalizeCanvasAgentOperationErrorCode,
@ -2043,6 +2048,39 @@ test.describe("canvas chat pure helpers", () => {
store.clearCanvas();
});
test("normalizes and interpolates canvas agent view animations", () => {
expect(normalizeCanvasAgentViewAnimationDuration(undefined)).toBe(280);
expect(normalizeCanvasAgentViewAnimationDuration(360)).toBe(360);
expect(normalizeCanvasAgentViewAnimationDuration(-20)).toBe(0);
expect(normalizeCanvasAgentViewAnimationDuration(1200)).toBe(900);
expect(easeOutCanvasAgentViewProgress(-1)).toBe(0);
expect(easeOutCanvasAgentViewProgress(1.5)).toBe(1);
expect(easeOutCanvasAgentViewProgress(0.5)).toBeCloseTo(0.875);
expect(
interpolateCanvasAgentViewport(
{ x: 0, y: 20, zoom: 1 },
{ x: 100, y: 220, zoom: 2 },
0
)
).toEqual({ x: 0, y: 20, zoom: 1 });
expect(
interpolateCanvasAgentViewport(
{ x: 0, y: 20, zoom: 1 },
{ x: 100, y: 220, zoom: 2 },
1
)
).toEqual({ x: 100, y: 220, zoom: 2 });
expect(
interpolateCanvasAgentViewport(
{ x: 0, y: 20, zoom: 1 },
{ x: 100, y: 220, zoom: 2 },
0.5
)
).toEqual({ x: 87.5, y: 195, zoom: 1.875 });
});
test("marks agent operation refresh windows for autosave guards", () => {
const store = useCanvasStore.getState();
store.clearAgentOperationRefresh();