fix: animate canvas agent viewport changes
This commit is contained in:
parent
0f74cbd6ff
commit
a1a095fe0e
@ -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(
|
||||
|
||||
35
lib/canvas/agent-view-animation.ts
Normal file
35
lib/canvas/agent-view-animation.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user