refactor: resolve canvas agent view operations
This commit is contained in:
parent
640e1b6aa6
commit
7d99330eb9
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user