fix 修正画布资源定位结果与失败反馈
抽取并透传画布操作结构化结果 区分画布资源不存在与其他定位失败提示 明确重复资源仅定位首个图层并补充文档与测试
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
+28
-16
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user