fix 修正画布资源定位结果与失败反馈
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 2m46s
Project CI / Native shell tests (pull_request) Failing after 9m17s

抽取并透传画布操作结构化结果
区分画布资源不存在与其他定位失败提示
明确重复资源仅定位首个图层并补充文档与测试
This commit is contained in:
2026-07-31 16:46:47 +08:00
parent 5d5d39358e
commit 7f9984d12d
7 changed files with 98 additions and 42 deletions
@@ -50,7 +50,7 @@
## 生成结果落画板(对现有占位规则的例外)
- 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由工具消息和外部任务状态承载。
- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。携带有效 `resourceId` 的图片结果在普通单击时直接调用同一 Context 的 `focusResource(resourceId)`;图片、视频或音频结果仍可从右键素材菜单选择“在画布中定位”。两种入口都请求画布视口在 `420ms` 内平滑 fit 到对应图层;结果卡片不进入键盘 Tab 顺序,Enter / Space 不触发画布聚焦,视频和音频的点击、播放、暂停、拖动、音量等原生交互只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。
- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。携带有效 `resourceId` 的图片结果在普通单击时直接调用同一 Context 的 `focusResource(resourceId)`;图片、视频或音频结果仍可从右键素材菜单选择“在画布中定位”。两种入口都请求画布视口在 `420ms` 内平滑 fit 到对应图层;同一 `resourceId` 对应多个画布图层时只聚焦当前图层顺序中的首个,不同时聚焦或轮询多个图层。结果卡片不进入键盘 Tab 顺序,Enter / Space 不触发画布聚焦,视频和音频的点击、播放、暂停、拖动、音量等原生交互只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。定位返回 `{ successed, reason? }`:画布中无对应图层时为 `reason="not-found-on-canva"`,右键菜单显示「画布上不存在」;其余失败使用 `reason="other"`,仅显示「失败」。
- 消息内生成结果缩略图必须携带并优先使用 `objectKey` / `assetObjectId`,前端通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签后渲染,不能把裸 `/generated-*` 私有路径直接交给 `<img>`
- 既有编辑器 worker 通过 `canvasCompletion` 写回工程快照;刷新后由 external generation task 状态和会话懒回填恢复结果。
- 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。
@@ -51,7 +51,7 @@ function renderMessage(
describe('MessageBubble', () => {
beforeEach(() => {
vi.clearAllMocks();
focusResourceMock.mockReturnValue({ found: true });
focusResourceMock.mockReturnValue({ successed: true });
vi.mocked(copyTextToClipboard).mockResolvedValue(true);
});
@@ -451,12 +451,27 @@ describe('MessageBubble', () => {
}
expect(focusResourceMock).toHaveBeenCalledTimes(4);
focusResourceMock.mockReturnValueOnce({ found: false });
focusResourceMock.mockReturnValueOnce({
successed: false,
reason: 'not-found-on-canva',
});
fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 });
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
expect(focusResourceMock).toHaveBeenLastCalledWith('resource-image');
expect(screen.getByRole('menuitem', { name: '定位失败' })).toBeTruthy();
expect(
screen.getByRole('menuitem', { name: '画布上不存在' }),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Escape' });
focusResourceMock.mockReturnValueOnce({
successed: false,
reason: 'other',
});
fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 });
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
expect(screen.getByRole('menuitem', { name: '失败' })).toBeTruthy();
});
it('exports right-click images through the native HostBridge', async () => {
@@ -1,6 +1,8 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { ImageCanvasActionResult } from '@/src/components/image-editor/ImageCanvasActionsContext.ts';
import {
contextAssetMediaSrc,
type EditorAgentContextAsset,
@@ -14,7 +16,7 @@ type MessageBubbleRightClickMenuProps = {
target: RightClickMenuTarget;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
result: ImageCanvasActionResult | null;
onAction: (action: EditorAgentRightClickAction) => void;
onClose: () => void;
};
@@ -32,36 +34,46 @@ function actionLabel({
idleLabel: string;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
result: ImageCanvasActionResult | null;
}) {
let actionState: 'pending' | 'success' | 'error' | null = null;
if (pendingAction === action) {
actionState = 'pending';
} else if (resultAction === action) {
actionState = result;
switch (action) {
case EditorAgentRightClickAction.FocusCanvas:
return '定位中';
case EditorAgentRightClickAction.DownloadAsset:
return '下载中';
case EditorAgentRightClickAction.ReferenceImage:
return '引用中';
case EditorAgentRightClickAction.CopyText:
case EditorAgentRightClickAction.CopyImage:
return '复制中';
}
}
if (!actionState) {
const actionResult = resultAction === action ? result : null;
if (!actionResult) {
return idleLabel;
}
switch (action) {
case EditorAgentRightClickAction.FocusCanvas:
if (actionState === 'pending') return '定位';
if (actionState === 'success') return '已定位';
return '定位失败';
if (actionResult.successed) return '定位';
switch (actionResult.reason) {
case 'not-found-on-canva':
return '画布上不存在';
case 'other':
default:
return '失败';
}
case EditorAgentRightClickAction.DownloadAsset:
if (actionState === 'pending') return '下载';
if (actionState === 'success') return '已下载';
if (actionResult.successed) return '下载';
return '下载失败';
case EditorAgentRightClickAction.ReferenceImage:
if (actionState === 'pending') return '引用';
if (actionState === 'success') return '已引用';
if (actionResult.successed) return '引用';
return '引用失败';
case EditorAgentRightClickAction.CopyText:
case EditorAgentRightClickAction.CopyImage:
if (actionState === 'pending') return '复制';
if (actionState === 'success') return '已复制';
if (actionResult.successed) return '复制';
return '复制失败';
}
}
@@ -4,7 +4,10 @@ import {
useState,
} from 'react';
import type { ImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts';
import type {
ImageCanvasActionResult,
ImageCanvasActions,
} from '@/src/components/image-editor/ImageCanvasActionsContext.ts';
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
import {
@@ -30,7 +33,7 @@ type RightClickMenuState = {
target: RightClickMenuTarget;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
result: ImageCanvasActionResult | null;
};
function sanitizeDownloadName(value: string, extension: string) {
@@ -236,15 +239,23 @@ async function downloadAsset(asset: EditorAgentContextAsset) {
function focusAssetOnCanvas(
asset: EditorAgentContextAsset,
onFocusResource?: ImageCanvasActions['focusResource'],
) {
): ReturnType<ImageCanvasActions['focusResource']> {
if (asset.kind !== 'generated_media' || !onFocusResource) {
return false;
return { successed: false, reason: 'other' };
}
const resourceId = asset.resourceId?.trim();
if (!resourceId) {
return false;
return { successed: false, reason: 'other' };
}
return onFocusResource(resourceId).found;
try {
return onFocusResource(resourceId);
} catch {
return { successed: false, reason: 'other' };
}
}
function actionResultFromSuccess(successed: boolean): ImageCanvasActionResult {
return successed ? { successed: true } : { successed: false, reason: 'other' };
}
export function useRightClickMenu({
@@ -293,16 +304,23 @@ export function useRightClickMenu({
}
: current,
);
let succeeded = false;
let actionResult: ImageCanvasActionResult = {
successed: false,
reason: 'other',
};
switch (action) {
case EditorAgentRightClickAction.CopyText:
if (target.kind === 'text') {
succeeded = await copyTextToClipboard(target.text);
actionResult = actionResultFromSuccess(
await copyTextToClipboard(target.text),
);
}
break;
case EditorAgentRightClickAction.CopyImage:
if (target.kind === 'asset' && target.asset.mediaType === 'image') {
succeeded = await copyAssetImage(target.asset);
actionResult = actionResultFromSuccess(
await copyAssetImage(target.asset),
);
}
break;
case EditorAgentRightClickAction.ReferenceImage:
@@ -311,17 +329,21 @@ export function useRightClickMenu({
target.asset.mediaType === 'image' &&
contextAssetMediaSrc(target.asset).trim()
) {
succeeded = onReferenceImage?.(target.asset) ?? false;
actionResult = actionResultFromSuccess(
onReferenceImage?.(target.asset) ?? false,
);
}
break;
case EditorAgentRightClickAction.FocusCanvas:
if (target.kind === 'asset') {
succeeded = focusAssetOnCanvas(target.asset, onFocusResource);
actionResult = focusAssetOnCanvas(target.asset, onFocusResource);
}
break;
case EditorAgentRightClickAction.DownloadAsset:
if (target.kind === 'asset') {
succeeded = await downloadAsset(target.asset);
actionResult = actionResultFromSuccess(
await downloadAsset(target.asset),
);
}
break;
}
@@ -329,14 +351,14 @@ export function useRightClickMenu({
if (current?.target !== target || current.pendingAction !== action) {
return current;
}
if (succeeded) {
if (actionResult.successed) {
return null;
}
return {
...current,
pendingAction: null,
resultAction: action,
result: 'error',
result: actionResult,
};
});
},
@@ -11,7 +11,7 @@ import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider';
describe('ImageCanvasActionsContext', () => {
it('exposes the editor-scoped canvas actions', () => {
const focusResource = vi.fn(() => ({ found: true }));
const focusResource = vi.fn(() => ({ successed: true }));
const refreshCanvas = vi.fn();
const { result } = renderHook(useImageCanvasActions, {
wrapper: ({ children }: { children: ReactNode }) => (
@@ -24,13 +24,13 @@ describe('ImageCanvasActionsContext', () => {
),
});
let focusResult: { found: boolean } | undefined;
let focusResult: { successed: boolean } | undefined;
act(() => {
focusResult = result.current.focusResource('resource-a');
result.current.refreshCanvas();
});
expect(focusResult).toEqual({ found: true });
expect(focusResult).toEqual({ successed: true });
expect(focusResource).toHaveBeenCalledWith('resource-a');
expect(refreshCanvas).toHaveBeenCalledTimes(1);
});
@@ -1,7 +1,12 @@
import { createContext, useContext } from 'react';
export interface ImageCanvasActionResult {
successed: boolean;
reason?: 'not-found-on-canva' | 'other';
}
export type ImageCanvasActions = {
focusResource: (resourceId: string) => { found: boolean };
focusResource: (resourceId: string) => ImageCanvasActionResult;
refreshCanvas: () => void;
};
@@ -32,6 +32,7 @@ import {
PlatformRechargePaymentResultDialog,
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
import type { ImageCanvasActionResult } from './ImageCanvasActionsContext';
import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider';
import {
canvasAssetKindOrNull,
@@ -901,19 +902,20 @@ export function ImageCanvasEditorView({
[animateViewportTo, canvasSize, setViewport],
);
const focusCanvasResource = useCallback(
(resourceId: string) => {
(resourceId: string): ImageCanvasActionResult => {
const normalizedResourceId = resourceId.trim();
if (!normalizedResourceId) {
return { found: false };
return { successed: false, reason: 'other' };
}
// 同一 resourceId 对应多个画布图层时,定位顺序中的首个图层,保持单目标聚焦行为。
const targetLayer = layersRef.current.find(
(layer) => layer.resourceId === normalizedResourceId,
);
if (!targetLayer) {
return { found: false };
return { successed: false, reason: 'not-found-on-canva' };
}
focusCanvasBounds(targetLayer, { animate: true });
return { found: true };
return { successed: true };
},
[focusCanvasBounds],
);