From a1a095fe0e6f85fab6acd99b4667305e91651e26 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 18:56:49 +0800 Subject: [PATCH] fix: animate canvas agent viewport changes --- components/canvas/CanvasChatPanel.tsx | 62 ++++++++++++++++++++++-- lib/canvas/agent-view-animation.ts | 35 +++++++++++++ tests/routes/canvas-chat-helpers.test.ts | 38 +++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 lib/canvas/agent-view-animation.ts diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 7dcee33b1..777359d88 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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>(new Set()); const pendingCanvasViewOperationRef = useRef(null); + const canvasViewAnimationFrameRef = useRef(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( diff --git a/lib/canvas/agent-view-animation.ts b/lib/canvas/agent-view-animation.ts new file mode 100644 index 000000000..393477a53 --- /dev/null +++ b/lib/canvas/agent-view-animation.ts @@ -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)), + }; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 8a054e97f..8b2999f78 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -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();