fix: center canvas agent writebacks without anchors

This commit is contained in:
134 2026-06-23 16:44:34 +08:00
parent 38dba2b1bc
commit 1e41335ebb
4 changed files with 142 additions and 10 deletions

View File

@ -1211,7 +1211,7 @@ canvasViewOperationPlan?: CanvasViewOperationPlan;
- 有 selected slot:`fill_slot`
- 有 selected image:`insert_beside`
- 无选中:`insert_beside` 当前视口或 `viewport-center`
- 无选中:`insert_beside` + `placement = "viewport-center"`,并在 `metadata.canvasAgentViewport` 写入请求时的 viewport(含 width/height),服务端 mutation 按真实当前视口中心计算落点
图片编辑:

View File

@ -168,6 +168,7 @@ export function buildCanvasAgentDirectWritebackPlan(
: params.mode === "edit"
? "append_revision"
: "insert_beside";
const hasWritebackAnchor = Boolean(anchorNode?.nodeId);
const mediaItems: CanvasMediaItem[] = resultUrls.map((url, index) => {
const mediaItemId = `${params.assistantMessageId}:${params.operationLabel}:${index + 1}`;
const itemSourceNodeIds = perResultSourceNodeIds[index]
@ -206,7 +207,7 @@ export function buildCanvasAgentDirectWritebackPlan(
sourceNodeIds,
targetSlotId: targetSlotId || undefined,
anchorNodeId: shouldFillSlot ? undefined : anchorNode?.nodeId,
placement: "right",
placement: hasWritebackAnchor ? "right" : "viewport-center",
placementStrategy:
perResultSourceNodeIds.length === mediaItems.length
? "beside_each_source"
@ -220,6 +221,7 @@ export function buildCanvasAgentDirectWritebackPlan(
chatId: params.chatId,
mode: params.mode,
prompt: params.prompt || params.requestTextContent || "",
canvasAgentViewport: params.context.viewport,
},
};

View File

@ -17,6 +17,14 @@ export type ResolvedCanvasAgentMediaPlacement = {
edgeSourceId?: string;
};
export type CanvasAgentPlacementViewport = {
x: number;
y: number;
zoom: number;
width?: number;
height?: number;
};
function uniqueStrings(values: Array<string | undefined | null>) {
return Array.from(
new Set(values.map((value) => String(value || "").trim()).filter(Boolean))
@ -40,11 +48,51 @@ function getNodeBounds(node?: CanvasAgentPlacementNode | null) {
};
}
function getViewportCenter(viewport?: { x: number; y: number; zoom: number }) {
const zoom = Math.max(0.1, Number(viewport?.zoom || 1) || 1);
function toFiniteNumber(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function normalizePlacementViewport(
value: unknown
): CanvasAgentPlacementViewport | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const x = toFiniteNumber(raw.x);
const y = toFiniteNumber(raw.y);
const zoom = toFiniteNumber(raw.zoom);
if (x === undefined || y === undefined || zoom === undefined) return undefined;
return {
x: -Number(viewport?.x || 0) / zoom,
y: -Number(viewport?.y || 0) / zoom,
x,
y,
zoom,
width: toFiniteNumber(raw.width),
height: toFiniteNumber(raw.height),
};
}
function getPlanViewport(plan: CanvasOperationPlan) {
const metadata =
plan.metadata && typeof plan.metadata === "object" && !Array.isArray(plan.metadata)
? (plan.metadata as Record<string, unknown>)
: {};
return normalizePlacementViewport(metadata.canvasAgentViewport);
}
function getEffectiveViewport(params: {
plan: CanvasOperationPlan;
viewport?: CanvasAgentPlacementViewport;
}) {
return getPlanViewport(params.plan) || params.viewport;
}
function getViewportCenter(viewport?: CanvasAgentPlacementViewport) {
const zoom = Math.max(0.1, Number(viewport?.zoom || 1) || 1);
const width = toFiniteNumber(viewport?.width);
const height = toFiniteNumber(viewport?.height);
return {
x: ((width && width > 0 ? width / 2 : 0) - Number(viewport?.x || 0)) / zoom,
y: ((height && height > 0 ? height / 2 : 0) - Number(viewport?.y || 0)) / zoom,
};
}
@ -54,7 +102,7 @@ function getViewportCenteredPosition(params: {
groupIndex: number;
size: { width: number; height: number };
strategy: CanvasOperationPlan["placementStrategy"];
viewport?: { x: number; y: number; zoom: number };
viewport?: CanvasAgentPlacementViewport;
}) {
const stepGap = 24;
const center = getViewportCenter(params.viewport);
@ -155,7 +203,7 @@ function resolvePosition(params: {
size: { width: number; height: number };
anchorNode?: CanvasAgentPlacementNode | null;
targetSlot?: CanvasAgentPlacementNode | null;
viewport?: { x: number; y: number; zoom: number };
viewport?: CanvasAgentPlacementViewport;
}) {
const gap = 40;
const stepGap = 24;
@ -258,9 +306,13 @@ export function resolveCanvasAgentMediaPlacements(params: {
mediaItems: CanvasMediaItem[];
nodes: CanvasAgentPlacementNode[];
targetSlot?: CanvasAgentPlacementNode | null;
viewport?: { x: number; y: number; zoom: number };
viewport?: CanvasAgentPlacementViewport;
}): ResolvedCanvasAgentMediaPlacement[] {
const groupCounts = new Map<string, number>();
const viewport = getEffectiveViewport({
plan: params.plan,
viewport: params.viewport,
});
return params.mediaItems.map((item, index) => {
const sourceNodeIds = getItemSourceNodeIds(item, params.plan);
@ -289,7 +341,7 @@ export function resolveCanvasAgentMediaPlacements(params: {
size,
anchorNode,
targetSlot: params.targetSlot,
viewport: params.viewport,
viewport,
});
return {

View File

@ -676,6 +676,53 @@ test.describe("canvas chat pure helpers", () => {
]);
});
test("plans direct generations into the current viewport when no anchor exists", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: [],
selectedNodes: [],
candidateNodes: [],
slots: [],
annotations: [],
viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 800 },
focusPolicy: "auto",
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-no-anchor",
operationLabel: "agent-generate-image",
resultType: "image",
resultUrls: ["https://cdn.example.com/no-anchor.png"],
prompt: "生成一张主图",
mode: "generate",
context,
});
expect(result).toMatchObject({
operation: "insert_beside",
shouldFillSlot: false,
plan: {
operation: "insert_beside",
anchorNodeId: undefined,
placement: "viewport-center",
placementStrategy: "single",
sourceNodeIds: [],
metadata: {
canvasAgentViewport: {
x: -200,
y: -100,
zoom: 1,
width: 1000,
height: 800,
},
},
},
});
expect(result?.mediaItems[0]?.sourceNodeIds).toEqual([]);
});
test("does not build direct writeback plans without valid result urls", () => {
expect(
buildCanvasAgentDirectWritebackPlan({
@ -2244,6 +2291,37 @@ test.describe("canvas chat pure helpers", () => {
{ x: 88, y: 60 },
{ x: 212, y: 60 },
]);
const viewportAwarePlacements = resolveCanvasAgentMediaPlacements({
plan: {
version: 1,
operationId: "op-center-with-stage",
operation: "insert_beside",
projectId: "project-1",
placement: "viewport-center",
placementStrategy: "grid",
metadata: {
canvasAgentViewport: {
x: -200,
y: -100,
zoom: 1,
width: 1000,
height: 800,
},
},
},
mediaItems: [
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
],
nodes: [],
viewport: { x: 0, y: 0, zoom: 1 },
});
expect(viewportAwarePlacements.map((placement) => placement.position)).toEqual([
{ x: 588, y: 460 },
{ x: 712, y: 460 },
]);
});
test("normalizes slot-only canvas agent context without image nodes", () => {