diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index a35821d08..8e6247687 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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( diff --git a/lib/canvas/agent-view-operations.ts b/lib/canvas/agent-view-operations.ts index dd33312fd..771ba06c6 100644 --- a/lib/canvas/agent-view-operations.ts +++ b/lib/canvas/agent-view-operations.ts @@ -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; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 11371ffa5..721ec9422 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -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: {