refactor: resolve canvas agent view operations

This commit is contained in:
134 2026-06-22 22:09:47 +08:00
parent 640e1b6aa6
commit 7d99330eb9
3 changed files with 275 additions and 71 deletions

View File

@ -97,8 +97,7 @@ import {
isCanvasImageUploadPending,
} from "@/lib/canvas/local-upload-state";
import {
computeCanvasAgentPanViewport,
resolveCanvasViewOperationTargetNodeIds,
resolveCanvasViewOperation,
} from "@/lib/canvas/agent-view-operations";
import { getCanvasAgentOperationErrorMessage } from "@/lib/canvas/agent-operation-errors";
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
@ -1830,79 +1829,29 @@ export function CanvasChatPanel({
if (!payload || typeof payload !== "object") return;
const targetProjectId = String(payload.projectId || "").trim();
if (targetProjectId && targetProjectId !== canvasId) return;
if (typeof window === "undefined") return;
const store = useCanvasStore.getState();
const currentNodes = store.nodes;
const selectedIds = store.selectedNodeIds.filter(Boolean);
const zoomByFactor = (factor: number) => {
if (typeof window === "undefined") return;
const currentViewport = useCanvasStore.getState().viewport;
const nextZoom = Math.max(0.1, Math.min(4, currentViewport.zoom * factor));
const anchorX = window.innerWidth * 0.52;
const anchorY = window.innerHeight * 0.46;
const scaleChange = nextZoom / currentViewport.zoom;
setTransientViewport({
x: anchorX - (anchorX - currentViewport.x) * scaleChange,
y: anchorY - (anchorY - currentViewport.y) * scaleChange,
zoom: nextZoom,
});
};
if (payload.operation === "zoom_in") {
zoomByFactor(1.22);
return;
}
if (payload.operation === "zoom_out") {
zoomByFactor(0.82);
return;
}
if (payload.operation === "zoom_to" && Number.isFinite(Number(payload.zoom))) {
const currentViewport = useCanvasStore.getState().viewport;
setTransientViewport({
...currentViewport,
zoom: Math.max(0.1, Math.min(4, Number(payload.zoom))),
});
return;
}
if (
payload.operation === "pan_to" &&
payload.center &&
typeof payload.center === "object" &&
typeof window !== "undefined"
) {
setTransientViewport(
computeCanvasAgentPanViewport({
currentViewport: useCanvasStore.getState().viewport,
center: payload.center,
stage: {
width: window.innerWidth,
height: window.innerHeight,
},
zoom: payload.zoom,
})
);
return;
}
const targetIds = resolveCanvasViewOperationTargetNodeIds({
const resolved = resolveCanvasViewOperation({
plan: payload,
nodes: currentNodes,
selectedNodeIds: selectedIds,
nodes: store.nodes,
selectedNodeIds: store.selectedNodeIds.filter(Boolean),
currentViewport: store.viewport,
stage: {
width: window.innerWidth,
height: window.innerHeight,
},
});
const targetNodes =
payload.operation === "fit_all"
? currentNodes.filter((node) =>
["image", "video", "agent-slot", "ui-screen", "text"].includes(String(node.type))
)
: currentNodes.filter((node) => targetIds.includes(node.id));
if (!resolved) return;
if (targetNodes.length === 0) return;
if (targetIds.length > 0) {
store.selectNodes(targetIds);
window.requestAnimationFrame(() => {
setTransientViewport(resolved.viewport);
});
if (resolved.targetNodeIds.length > 0) {
store.selectNodes(resolved.targetNodeIds);
}
focusCanvasOnNodes(targetNodes, Number(payload.padding || 120));
},
[canvasId, focusCanvasOnNodes, setTransientViewport]
[canvasId, setTransientViewport]
);
const handleAssistantVote = useCallback(

View File

@ -1,6 +1,6 @@
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
type CanvasAgentViewNodeLike = {
export type CanvasAgentViewNodeLike = {
id: string;
type?: string;
position?: { x?: number; y?: number };
@ -10,17 +10,22 @@ type CanvasAgentViewNodeLike = {
updatedAt?: string | number | Date;
};
type CanvasAgentViewportLike = {
export type CanvasAgentViewportLike = {
x: number;
y: number;
zoom: number;
};
type CanvasAgentStageLike = {
export type CanvasAgentStageLike = {
width: number;
height: number;
};
export type ResolvedCanvasViewOperation = {
viewport: CanvasAgentViewportLike;
targetNodeIds: string[];
};
const VIEWPORT_ANCHOR_X_RATIO = 0.52;
const VIEWPORT_ANCHOR_Y_RATIO = 0.46;
@ -120,3 +125,174 @@ export function computeCanvasAgentPanViewport(params: {
zoom,
};
}
function getNodeBounds(node?: CanvasAgentViewNodeLike | null) {
if (!node) return null;
const width = Math.max(1, toFiniteNumber(node.size?.width, 300));
const height = Math.max(1, toFiniteNumber(node.size?.height, 300));
return {
x: toFiniteNumber(node.position?.x, 0),
y: toFiniteNumber(node.position?.y, 0),
width,
height,
};
}
function computeCanvasAgentFocusNodeViewport(params: {
node: CanvasAgentViewNodeLike;
stage: CanvasAgentStageLike;
}) {
const bounds = getNodeBounds(params.node);
if (!bounds) return null;
const stageWidth = Math.max(1, toFiniteNumber(params.stage.width, 1200));
const stageHeight = Math.max(1, toFiniteNumber(params.stage.height, 800));
const availableWidth = Math.max(360, stageWidth * 0.54);
const availableHeight = Math.max(320, stageHeight * 0.52);
const fitZoom = Math.min(
1.16,
availableWidth / bounds.width,
availableHeight / bounds.height
);
const zoom = Math.max(0.48, Math.min(fitZoom, 4));
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return computeCanvasAgentPanViewport({
currentViewport: { x: 0, y: 0, zoom: 1 },
center: { x: centerX, y: centerY },
stage: params.stage,
zoom,
});
}
function computeCanvasAgentFocusNodesViewport(params: {
nodes: CanvasAgentViewNodeLike[];
stage: CanvasAgentStageLike;
padding?: number;
}) {
if (params.nodes.length === 0) return null;
if (params.nodes.length === 1) {
return computeCanvasAgentFocusNodeViewport({
node: params.nodes[0],
stage: params.stage,
});
}
const bounds = params.nodes.map(getNodeBounds).filter(Boolean) as Array<{
x: number;
y: number;
width: number;
height: number;
}>;
if (bounds.length === 0) return null;
const padding = Math.max(0, toFiniteNumber(params.padding, 120));
const minX = Math.min(...bounds.map((bound) => bound.x));
const minY = Math.min(...bounds.map((bound) => bound.y));
const maxX = Math.max(...bounds.map((bound) => bound.x + bound.width));
const maxY = Math.max(...bounds.map((bound) => bound.y + bound.height));
const contentWidth = Math.max(1, maxX - minX + padding * 2);
const contentHeight = Math.max(1, maxY - minY + padding * 2);
const stageWidth = Math.max(1, toFiniteNumber(params.stage.width, 1200));
const stageHeight = Math.max(1, toFiniteNumber(params.stage.height, 800));
const availableWidth = Math.max(360, stageWidth * 0.54);
const availableHeight = Math.max(320, stageHeight * 0.58);
const zoom = Math.max(
0.08,
Math.min(1.4, availableWidth / contentWidth, availableHeight / contentHeight, 4)
);
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
return computeCanvasAgentPanViewport({
currentViewport: { x: 0, y: 0, zoom: 1 },
center: { x: centerX, y: centerY },
stage: params.stage,
zoom,
});
}
function getFitAllViewNodes(nodes: CanvasAgentViewNodeLike[]) {
const visibleNodeTypes = new Set(["image", "video", "agent-slot", "ui-screen", "text"]);
return nodes.filter((node) => visibleNodeTypes.has(String(node.type)));
}
export function resolveCanvasViewOperation(params: {
plan: CanvasViewOperationPlan;
nodes: CanvasAgentViewNodeLike[];
selectedNodeIds?: string[];
currentViewport: CanvasAgentViewportLike;
stage: CanvasAgentStageLike;
}): ResolvedCanvasViewOperation | null {
const operation = params.plan.operation;
const stage = {
width: Math.max(1, toFiniteNumber(params.stage.width, 1200)),
height: Math.max(1, toFiniteNumber(params.stage.height, 800)),
};
const currentViewport = {
x: toFiniteNumber(params.currentViewport.x, 0),
y: toFiniteNumber(params.currentViewport.y, 0),
zoom: Math.max(0.1, toFiniteNumber(params.currentViewport.zoom, 1)),
};
if (operation === "zoom_in" || operation === "zoom_out") {
const factor = operation === "zoom_in" ? 1.22 : 0.82;
const nextZoom = Math.max(0.1, Math.min(4, currentViewport.zoom * factor));
const anchorX = stage.width * VIEWPORT_ANCHOR_X_RATIO;
const anchorY = stage.height * VIEWPORT_ANCHOR_Y_RATIO;
const scaleChange = nextZoom / currentViewport.zoom;
return {
viewport: {
x: anchorX - (anchorX - currentViewport.x) * scaleChange,
y: anchorY - (anchorY - currentViewport.y) * scaleChange,
zoom: nextZoom,
},
targetNodeIds: [],
};
}
if (operation === "zoom_to" && Number.isFinite(Number(params.plan.zoom))) {
return {
viewport: {
...currentViewport,
zoom: Math.max(0.1, Math.min(4, Number(params.plan.zoom))),
},
targetNodeIds: [],
};
}
if (operation === "pan_to" && params.plan.center) {
return {
viewport: computeCanvasAgentPanViewport({
currentViewport,
center: params.plan.center,
stage,
zoom: params.plan.zoom,
}),
targetNodeIds: [],
};
}
const targetNodeIds = resolveCanvasViewOperationTargetNodeIds({
plan: params.plan,
nodes: params.nodes,
selectedNodeIds: params.selectedNodeIds,
});
const targetNodes =
operation === "fit_all"
? getFitAllViewNodes(params.nodes)
: params.nodes.filter((node) => targetNodeIds.includes(node.id));
const viewport = computeCanvasAgentFocusNodesViewport({
nodes: targetNodes,
stage,
padding: params.plan.padding,
});
return viewport
? {
viewport,
targetNodeIds,
}
: null;
}

View File

@ -45,6 +45,7 @@ import {
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
import {
computeCanvasAgentPanViewport,
resolveCanvasViewOperation,
resolveCanvasViewOperationTargetNodeIds,
} from "@/lib/canvas/agent-view-operations";
import {
@ -1260,6 +1261,84 @@ test.describe("canvas chat pure helpers", () => {
});
});
test("resolves canvas view operation plans into transient viewport targets", () => {
const zoomIn = resolveCanvasViewOperation({
plan: {
version: 1,
viewOperationId: "view-zoom-in",
operation: "zoom_in",
},
nodes: [],
currentViewport: { x: 10, y: 20, zoom: 2 },
stage: { width: 1000, height: 800 },
});
expect(zoomIn?.targetNodeIds).toEqual([]);
expect(zoomIn?.viewport.zoom).toBeCloseTo(2.44);
expect(zoomIn?.viewport.x).toBeCloseTo(-102.2);
expect(zoomIn?.viewport.y).toBeCloseTo(-56.56);
const focusSelection = resolveCanvasViewOperation({
plan: {
version: 1,
viewOperationId: "view-focus-selection",
operation: "focus_selection",
},
nodes: [
{
id: "image-1",
type: "image",
position: { x: 100, y: 120 },
size: { width: 320, height: 240 },
},
],
selectedNodeIds: ["image-1"],
currentViewport: { x: 0, y: 0, zoom: 1 },
stage: { width: 1000, height: 800 },
});
expect(focusSelection?.targetNodeIds).toEqual(["image-1"]);
expect(focusSelection?.viewport.x).toBeCloseTo(218.4);
expect(focusSelection?.viewport.y).toBeCloseTo(89.6);
expect(focusSelection?.viewport.zoom).toBeCloseTo(1.16);
const fitAll = resolveCanvasViewOperation({
plan: {
version: 1,
viewOperationId: "view-fit-all",
operation: "fit_all",
padding: 100,
},
nodes: [
{
id: "image-1",
type: "image",
position: { x: 0, y: 0 },
size: { width: 200, height: 100 },
},
{
id: "text-1",
type: "text",
position: { x: 500, y: 200 },
size: { width: 100, height: 100 },
},
{
id: "workflow-1",
type: "workflow",
position: { x: 2000, y: 2000 },
size: { width: 400, height: 400 },
},
],
currentViewport: { x: 0, y: 0, zoom: 1 },
stage: { width: 1000, height: 800 },
});
expect(fitAll?.targetNodeIds).toEqual([]);
expect(fitAll?.viewport.zoom).toBeCloseTo(0.675);
expect(fitAll?.viewport.x).toBeCloseTo(317.5);
expect(fitAll?.viewport.y).toBeCloseTo(266.75);
});
test("resolves grid media placements to the right of an anchor node", () => {
const placements = resolveCanvasAgentMediaPlacements({
plan: {